diff --git a/.claude/skills/update-libtmux/SKILL.md b/.claude/skills/update-libtmux/SKILL.md new file mode 100644 index 0000000000..530ae6d9e1 --- /dev/null +++ b/.claude/skills/update-libtmux/SKILL.md @@ -0,0 +1,359 @@ +--- +name: update-libtmux +description: >- + Use when the user asks to "update libtmux", "bump libtmux", + "upgrade libtmux dependency", "check for new libtmux version", + or when investigating whether tmuxp needs a libtmux update. + Guides the full workflow: studying upstream changes, updating + the dependency, migrating code and tests, and producing + separate atomic commits with rich messages. +user-invocable: true +argument-hint: "[target-version] (optional, defaults to latest on PyPI)" +--- + +# Update libtmux Dependency + +Workflow for updating the libtmux dependency in tmuxp with separate, atomic commits. + +## Overview + +This skill produces up to four atomic commits on a dedicated branch, then opens a PR: + +1. **Package commit** — bump `pyproject.toml` + `uv.lock` +2. **Code commit(s)** — API migrations, new feature adoption (if needed) +3. **Test commit(s)** — test updates for changed/new APIs (if needed) +4. **CHANGES commit** — changelog entry documenting the bump + +Each commit stands alone, passes tests independently, and has a rich commit body. + +## Step 0: Preflight + +Gather current state before making any changes. + +### 0a. Current dependency + +Read `pyproject.toml` and find the `libtmux~=X.Y.Z` specifier in `[project] dependencies`. + +### 0b. Latest version on PyPI + +```bash +pip index versions libtmux +``` + +If the user provided a target version, use that. Otherwise use the latest from PyPI. + +### 0c. Short-circuit check + +If the current specifier already covers the target version, inform the user and stop. + +### 0d. Ensure local libtmux clone is current + +The local libtmux clone lives at `~/work/python/libtmux`. Fetch and check: + +```bash +cd ~/work/python/libtmux && git fetch --tags && git log --oneline -5 +``` + +Verify the target version tag exists. If not, the version may not be released yet — warn the user. + +## Step 1: Study upstream changes + +This is the most important step. Read the libtmux CHANGES file to understand what changed between the current pinned version and the target. + +### 1a. Read libtmux CHANGES + +Read `~/work/python/libtmux/CHANGES` from the section for the target version back through all versions since the current pin. + +Categorize changes into: + +| Category | Action needed in tmuxp | +|----------|----------------------| +| **Breaking changes** | Must fix code/tests | +| **Deprecations** | Should migrate away | +| **New APIs** | Optionally adopt | +| **Bug fixes** | Note for commit message | +| **Internal/docs** | Note for commit message only | + +### 1b. Check for API impact in tmuxp + +For each breaking change or deprecation, grep tmuxp source and tests: + +```bash +# Example: if Window.rename_window() changed signature +rg "rename_window" src/ tests/ +``` + +Search patterns to check (adapt based on actual changes): +- Method/function names that changed +- Constructor parameters that changed +- Import paths that moved +- Exception types that changed +- Return type changes + +### 1c. Check libtmux git log for details + +For breaking changes where the CHANGES entry is unclear, read the actual commits: + +```bash +cd ~/work/python/libtmux && git log --oneline v{CURRENT}..v{TARGET} -- src/ +``` + +### 1d. Summarize findings + +Present findings to the user before proceeding: +- Versions being skipped (e.g., "0.53.1, 0.54.0, 0.55.0") +- Breaking changes requiring code updates +- New APIs available for adoption +- Test impact assessment +- Estimated commit count + +Get user confirmation to proceed. + +## Step 2: Create branch + +```bash +git checkout -b deps/libtmux-{TARGET_VERSION} +``` + +Branch naming convention: `deps/libtmux-X.Y.Z` + +## Step 3: Package commit + +Update the dependency specifier and lock file. + +### 3a. Edit pyproject.toml + +Change the `libtmux~=X.Y.Z` line in `[project] dependencies`. + +### 3b. Update lock file + +```bash +uv lock +``` + +### 3c. Verify installation + +```bash +uv sync +``` + +### 3d. Run tests (smoke check) + +```bash +uv run py.test tests/ -x -q 2>&1 | tail -20 +``` + +Note any failures — these indicate code changes needed in Step 4. + +### 3e. Commit + +Commit message format (use heredoc for multiline): + +``` +deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C + +why: Pick up N libtmux release(s) (list versions) bringing +[brief summary of key changes]. + +what: +- Bump libtmux dependency specifier ~=A.B.C -> ~=X.Y.Z in pyproject.toml +- Update uv.lock + +libtmux X.Y.Z (date): +- [key change 1] +- [key change 2] + +[repeat for each intermediate version] + +Release: https://github.com/tmux-python/libtmux/releases/tag/vX.Y.Z +Changelog: https://libtmux.git-pull.com/history.html#libtmux-X-Y-Z-YYYY-MM-DD +``` + +Stage only `pyproject.toml` and `uv.lock`. + +## Step 4: Code commit(s) — if needed + +Skip this step if no breaking changes or API migrations are needed. + +### 4a. Fix breaking changes + +Address each breaking change identified in Step 1b. Make minimal, targeted fixes. + +### 4b. Adopt new APIs (optional) + +Only if the user requested it or it simplifies existing code significantly. + +### 4c. Run linting and type checking + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +``` + +### 4d. Run tests + +```bash +uv run py.test tests/ -x -q +``` + +### 4e. Commit + +One commit per logical change. Format: + +``` +Scope(type[detail]): description of the migration + +why: libtmux X.Y.Z changed [what changed]. +what: +- [specific code change 1] +- [specific code change 2] +``` + +Use the project's standard scope conventions: +- `workspace/builder(fix[method])` for builder changes +- `cli/load(fix[feature])` for CLI changes +- `plugin(fix[hook])` for plugin changes + +## Step 5: Test commit(s) — if needed + +Skip if no test changes are required beyond what was fixed in Step 4. + +### 5a. Update tests for API changes + +Fix any tests that broke due to upstream changes. + +### 5b. Add tests for newly adopted APIs + +If Step 4 adopted new libtmux features, add tests. + +### 5c. Run full test suite + +```bash +uv run py.test +``` + +All tests must pass (doctests included — pytest is configured with `--doctest-modules`). + +### 5d. Commit + +``` +tests(scope[detail]): description + +why: Adapt tests for libtmux X.Y.Z [change]. +what: +- [specific test change 1] +- [specific test change 2] +``` + +## Step 6: CHANGES commit + +### 6a. Determine placement + +The CHANGES file has a placeholder section for the next unreleased version at the top. Add the entry below the placeholder comments. + +### 6b. Write the entry + +Add under `### Breaking Changes` if the bump changes minimum version, or `### Development` / `### Dependencies` for non-breaking bumps: + +For a breaking bump: + +```markdown +#### **libtmux** minimum bumped from `~=A.B.C` to `~=X.Y.Z` + + Picks up N releases: [version list with brief descriptions]. +``` + +For a non-breaking bump, use `### Dependencies`: + +```markdown +### Dependencies + +- Bump libtmux `~=A.B.C` -> `~=X.Y.Z` ([key changes summary]) +``` + +### 6c. Commit + +``` +docs(CHANGES): libtmux ~=A.B.C -> ~=X.Y.Z + +why: Document the dependency bump for the upcoming release. +what: +- Add entry for libtmux bump under [section name] +- Summarize key upstream changes +``` + +## Step 7: Push and open PR + +### 7a. Push the branch + +```bash +git push -u origin deps/libtmux-{TARGET_VERSION} +``` + +### 7b. Open PR + +```bash +gh pr create \ + --title "deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C" \ + --body "$(cat <<'EOF' +## Summary + +- Bump libtmux from `~=A.B.C` to `~=X.Y.Z` +- [N] upstream releases included +- [Breaking changes summary, or "No breaking changes"] + +## Upstream changes + +### libtmux X.Y.Z (date) +- [changes] + +[repeat for intermediate versions] + +## Changes in this PR + +- **Package**: pyproject.toml + uv.lock +- **Code**: [summary or "No code changes needed"] +- **Tests**: [summary or "No test changes needed"] +- **CHANGES**: Documented bump + +## Test plan + +- [ ] `uv run py.test` passes +- [ ] `uv run mypy` passes +- [ ] `uv run ruff check .` passes +EOF +)" +``` + +### 7c. Report to user + +Provide the PR URL and a summary of all commits created. + +## Reference: Past libtmux bumps + +These exemplar commits show the established patterns: + +| Version bump | Deps commit | CHANGES commit | PR | +|---|---|---|---| +| 0.53.0 → 0.55.0 | `ff52d0d2` | `094800f4` | #1019 | +| 0.52.1 → 0.53.0 | `5ff6400f` | `240d85fe` | #1003 | +| 0.51.0 → 0.52.1 | `fabd678f` | (in same commit) | #1001 | +| 0.50.1 → 0.51.0 | (in merge) | (in merge) | #999 | + +The 0.53→0.55 bump (`ff52d0d2`) is the gold standard for commit message richness — per-version changelogs, upstream links, and clear why/what structure. + +## Checklist + +Use this as a progress tracker: + +- [ ] Preflight: identify current and target versions +- [ ] Study upstream CHANGES and identify impact +- [ ] Summarize findings and get user confirmation +- [ ] Create branch `deps/libtmux-X.Y.Z` +- [ ] Package commit: pyproject.toml + uv.lock +- [ ] Code commit(s): API migrations (if needed) +- [ ] Test commit(s): test updates (if needed) +- [ ] CHANGES commit: changelog entry +- [ ] Push and open PR +- [ ] Report PR URL to user diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..bf9b9abef6 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,15 @@ +codecov: + notify: + require_ci_to_pass: no + +coverage: + precision: 2 + round: down + range: "70...100" + status: + project: + default: + target: auto + threshold: 1% + base: auto + patch: off diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 2784a20bef..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,15 +0,0 @@ -[run] -omit = - tests/* - */_vendor/* - */_* - pkg/* - */log.py - -[report] -exclude_lines = - pragma: no cover - def __repr__ - raise NotImplementedError - if __name__ == .__main__.: - def parse_args diff --git a/.cursor/rules/avoid-debug-loops.mdc b/.cursor/rules/avoid-debug-loops.mdc new file mode 100644 index 0000000000..8a241ec990 --- /dev/null +++ b/.cursor/rules/avoid-debug-loops.mdc @@ -0,0 +1,57 @@ +--- +description: When stuck in debugging loops, break the cycle by minimizing to an MVP, removing debugging cruft, and documenting the issue completely for a fresh approach +globs: *.py +alwaysApply: true +--- +# Avoid Debug Loops + +When debugging becomes circular and unproductive, follow these steps: + +## Detection +- You have made multiple unsuccessful attempts to fix the same issue +- You are adding increasingly complex code to address errors +- Each fix creates new errors in a cascading pattern +- You are uncertain about the root cause after 2-3 iterations + +## Action Plan + +1. **Pause and acknowledge the loop** + - Explicitly state that you are in a potential debug loop + - Review what approaches have been tried and failed + +2. **Minimize to MVP** + - Remove all debugging cruft and experimental code + - Revert to the simplest version that demonstrates the issue + - Focus on isolating the core problem without added complexity + +3. **Comprehensive Documentation** + - Provide a clear summary of the issue + - Include minimal but complete code examples that reproduce the problem + - Document exact error messages and unexpected behaviors + - Explain your current understanding of potential causes + +4. **Format for Portability** + - Present the problem in quadruple backticks for easy copying: + +```` +# Problem Summary +[Concise explanation of the issue] + +## Minimal Reproduction Code +```python +# Minimal code example that reproduces the issue +``` + +## Error/Unexpected Output +``` +[Exact error messages or unexpected output] +``` + +## Failed Approaches +[Brief summary of approaches already tried] + +## Suspected Cause +[Your current hypothesis about what might be causing the issue] +```` + +This format enables the user to easily copy the entire problem statement into a fresh conversation for a clean-slate approach. diff --git a/.cursor/rules/dev-loop.mdc b/.cursor/rules/dev-loop.mdc new file mode 100644 index 0000000000..d60a521095 --- /dev/null +++ b/.cursor/rules/dev-loop.mdc @@ -0,0 +1,187 @@ +--- +description: QA every edit +globs: *.py +alwaysApply: true +--- + +# Development Process + +## Project Stack + +The project uses the following tools and technologies: + +- **uv** - Python package management and virtual environments +- **ruff** - Fast Python linter and formatter +- **py.test** - Testing framework + - **pytest-watcher** - Continuous test runner +- **mypy** - Static type checking +- **doctest** - Testing code examples in documentation + +## 1. Start with Formatting + +Format your code first: + +``` +uv run ruff format . +``` + +## 2. Run Tests + +Verify that your changes pass the tests: + +``` +uv run py.test +``` + +For continuous testing during development, use pytest-watcher: + +``` +# Watch all tests +uv run ptw . + +# Watch and run tests immediately, including doctests +uv run ptw . --now --doctest-modules + +# Watch specific files or directories +uv run ptw . --now --doctest-modules src/libtmux/_internal/ +``` + +## 3. Commit Initial Changes + +Make an atomic commit for your changes using conventional commits. +Use `@git-commits.mdc` for assistance with commit message standards. + +## 4. Run Linting and Type Checking + +Check and fix linting issues: + +``` +uv run ruff check . --fix --show-fixes +``` + +Check typings: + +``` +uv run mypy +``` + +## 5. Verify Tests Again + +Ensure tests still pass after linting and type fixes: + +``` +uv run py.test +``` + +## 6. Final Commit + +Make a final commit with any linting/typing fixes. +Use `@git-commits.mdc` for assistance with commit message standards. + +## Development Loop Guidelines + +If there are any failures at any step due to your edits, fix them before proceeding to the next step. + +## Python Code Standards + +### Docstring Guidelines + +For `src/**/*.py` files, follow these docstring guidelines: + +1. **Use reStructuredText format** for all docstrings. + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + +2. **Keep the main description on the first line** after the opening `"""`. + +3. **Use NumPy docstyle** for parameter and return value documentation. + +### Doctest Guidelines + +For doctests in `src/**/*.py` files: + +1. **Use narrative descriptions** for test sections rather than inline comments: + ```python + """Example function. + + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` + +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` if they require elaborate setup or multiple steps. + +3. **Utilize pytest fixtures** via `doctest_namespace` for more complex test scenarios: + ```python + """Example with fixture. + + Examples + -------- + >>> # doctest_namespace contains all pytest fixtures from conftest.py + >>> example_fixture = getfixture('example_fixture') + >>> example_fixture.method() + 'expected result' + """ + ``` + +4. **Keep doctests simple and focused** on demonstrating usage rather than comprehensive testing. + +5. **Add blank lines between test sections** for improved readability. + +6. **Test your doctests continuously** using pytest-watcher during development: + ``` + # Watch specific modules for doctest changes + uv run ptw . --now --doctest-modules src/path/to/module.py + ``` + +### Pytest Testing Guidelines + +1. **Use existing fixtures over mocks**: + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For instance, if using libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` + - Document in test docstrings why standard fixtures weren't used for exceptional cases + +2. **Preferred pytest patterns**: + - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` + - Use `monkeypatch` fixture over `unittest.mock` + +### Import Guidelines + +1. **Prefer namespace imports**: + - Import modules and access attributes through the namespace instead of importing specific symbols + - Example: Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like `pathlib`, `os`, and similar cases + +2. **Standard aliases**: + - For `typing` module, use `import typing as t` + - Access typing elements via the namespace: `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes and primitive types like list and dict can be done via `list` and `dict` directly. + +3. **Benefits of namespace imports**: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable diff --git a/.cursor/rules/git-commits.mdc b/.cursor/rules/git-commits.mdc new file mode 100644 index 0000000000..f9c0980db7 --- /dev/null +++ b/.cursor/rules/git-commits.mdc @@ -0,0 +1,95 @@ +--- +description: git-commits: Git commit message standards and AI assistance +globs: git-commits: Git commit message standards and AI assistance | *.git/* .gitignore .github/* CHANGELOG.md CHANGES.md +alwaysApply: true +--- +# Optimized Git Commit Standards + +## Commit Message Format +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic + +refs: #issue-number, breaking changes, or relevant links +``` + +## Component Patterns +### General Code Changes +``` +Component/File(feat[method]): Add feature +Component/File(fix[method]): Fix bug +Component/File(refactor[method]): Code restructure +``` + +### Packages and Dependencies +| Language | Standard Packages | Dev Packages | Extras / Sub-packages | +|------------|------------------------------------|-------------------------------|-----------------------------------------------| +| General | `lang(deps):` | `lang(deps[dev]):` | | +| Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | +| JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | + +#### Examples +- `py(deps[dev]): Update pytest to v8.1` +- `js(deps[ui-components]): Upgrade Button component package` +- `js(deps[dev{linting}]): Add ESLint plugin` + +### Documentation Changes +Prefix with `docs:` +``` +docs(Component/File[Subcomponent/method]): Update API usage guide +``` + +### Test Changes +Prefix with `tests:` +``` +tests(Component/File[Subcomponent/method]): Add edge case tests +``` + +## Commit Types Summary +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +## General Guidelines +- Subject line: Maximum 50 characters +- Body lines: Maximum 72 characters +- Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") +- Limit to one topic per commit +- Separate subject from body with a blank line +- Mark breaking changes clearly: `BREAKING:` +- Use `See also:` to provide external references + +## AI Assistance Workflow in Cursor +- Stage changes with `git add` +- Use `@commit` to generate initial commit message +- Review and refine generated message +- Ensure adherence to these standards + +## Good Commit Example +``` +Pane(feat[capture_pane]): Add screenshot capture support + +why: Provide visual debugging capability +what: +- Implement capturePane method with image export +- Integrate with existing Pane component logic +- Document usage in Pane README + +refs: #485 +See also: https://example.com/docs/pane-capture +``` + +## Bad Commit Example +``` +fixed stuff and improved some functions +``` + +These guidelines ensure clear, consistent commit histories, facilitating easier code review and maintenance. \ No newline at end of file diff --git a/.cursor/rules/notes-llms-txt.mdc b/.cursor/rules/notes-llms-txt.mdc new file mode 100644 index 0000000000..ac17097737 --- /dev/null +++ b/.cursor/rules/notes-llms-txt.mdc @@ -0,0 +1,42 @@ +--- +description: LLM-friendly markdown format for notes directories +globs: notes/**/*.md,**/notes/**/*.md +alwaysApply: true +--- + +# Instructions for Generating LLM-Optimized Markdown Content + +When creating or editing markdown files within the specified directories, adhere to the following guidelines to ensure the content is optimized for LLM understanding and efficient token usage: + +1. **Conciseness and Clarity**: + - **Be Brief**: Present information succinctly, avoiding unnecessary elaboration. + - **Use Clear Language**: Employ straightforward language to convey ideas effectively. + +2. **Structured Formatting**: + - **Headings**: Utilize markdown headings (`#`, `##`, `###`, etc.) to organize content hierarchically. + - **Lists**: Use bullet points (`-`) or numbered lists (`1.`, `2.`, etc.) to enumerate items clearly. + - **Code Blocks**: Enclose code snippets within triple backticks (```) to distinguish them from regular text. + +3. **Semantic Elements**: + - **Emphasis**: Use asterisks (`*`) or underscores (`_`) for italicizing text to denote emphasis. + - **Strong Emphasis**: Use double asterisks (`**`) or double underscores (`__`) for bold text to highlight critical points. + - **Inline Code**: Use single backticks (`) for inline code references. + +4. **Linking and References**: + - **Hyperlinks**: Format links using `[Link Text](mdc:URL)` to provide direct access to external resources. + - **References**: When citing sources, use footnotes or inline citations to maintain readability. + +5. **Avoid Redundancy**: + - **Eliminate Repetition**: Ensure that information is not unnecessarily repeated within the document. + - **Use Summaries**: Provide brief summaries where detailed explanations are not essential. + +6. **Standard Compliance**: + - **llms.txt Conformance**: Structure the document in alignment with the `llms.txt` standard, which includes: + - An H1 heading with the project or site name. + - A blockquote summarizing the project's purpose. + - Additional markdown sections providing detailed information. + - H2-delimited sections containing lists of URLs for further details. + +By following these guidelines, the markdown files will be tailored for optimal LLM processing, ensuring that the content is both accessible and efficiently tokenized for AI applications. + +For more information on the `llms.txt` standard, refer to the official documentation: https://llmstxt.org/ diff --git a/.cursor/rules/tmuxp-pytest.mdc b/.cursor/rules/tmuxp-pytest.mdc new file mode 100644 index 0000000000..579809cc64 --- /dev/null +++ b/.cursor/rules/tmuxp-pytest.mdc @@ -0,0 +1,77 @@ +--- +description: Guidelines for using pytest with tmuxp, including libtmux fixtures +globs: tests/**/test_*.py +alwaysApply: true +--- + +# tmuxp Pytest Guidelines + +## Leveraging libtmux fixtures + +tmuxp tests can utilize libtmux's pytest fixtures for fast, efficient tmux server, session, window, and pane setup/teardown. These fixtures automatically manage the lifecycle of tmux resources during tests. + +### Available fixtures + +The following fixtures are available through libtmux's pytest plugin: + +- `server`: Creates a temporary tmux server with isolated socket +- `session`: Creates a temporary tmux session in the server +- `window`: Creates a temporary tmux window in the session +- `pane`: Creates a temporary tmux pane in the pane +- `TestServer`: Factory for creating multiple independent servers with unique socket names + +### Usage in tests + +```python +def test_something_with_server(server): + # server is already running with proper configuration + my_session = server.new_session("test-session") + assert server.is_alive() + assert my_session in server.sessions + +def test_something_with_session(session): + # session is already created and configured + new_window = session.new_window("test-window") + assert new_window in session.windows + +def test_with_multiple_servers(TestServer): + # Create independent servers for comparison tests + Server1 = TestServer() + Server2 = TestServer() + + server1 = Server1() + server2 = Server2() + + session1 = server1.new_session() + session2 = server2.new_session() + + assert server1.socket_path != server2.socket_path +``` + +### Customizing session parameters + +You can override the `session_params` fixture to customize session creation: + +```python +import pytest + +@pytest.fixture +def session_params(): + return { + 'x': 800, + 'y': 600, + 'window_name': 'custom-window' + } +``` + +### Benefits + +- No need to manually set up and tear down tmux infrastructure +- Tests run in isolated tmux environments +- Faster test execution +- Reliable test environment with predictable configuration +- Multiple tests can run in parallel without tmux session conflicts + +For more details, see: +- [libtmux pytest plugin documentation](https://libtmux.git-pull.com/pytest-plugin/index.html) +- [libtmux pytest plugin code](https://github.com/tmux-python/libtmux/blob/master/src/libtmux/pytest_plugin.py) diff --git a/.github/contributing.md b/.github/contributing.md new file mode 100644 index 0000000000..c0eddac2c2 --- /dev/null +++ b/.github/contributing.md @@ -0,0 +1,27 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the maintainers of this repository before making a change. + +See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for +detailed coding standards. + +## Pull Request Process + +1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` +2. **Type check**: `uv run mypy` +3. **Test**: `uv run pytest` — all tests must pass before submitting +4. **Document**: Update docs if your change affects the public interface +5. You may merge the Pull Request once you have the sign-off of one other developer. If you + do not have permission to do that, you may request a reviewer to merge it for you. + +## Decorum + +- Participants will be tolerant of opposing views. +- Participants must ensure that their language and actions are free of personal + attacks and disparaging personal remarks. +- When interpreting the words and actions of others, participants should always + assume good intentions. +- Behaviour which can be reasonably considered harassment will not be tolerated. + +Based on [Ruby's Community Conduct Guideline](https://www.ruby-lang.org/en/conduct/) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..d202a332d2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 0000000000..980833e876 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,34 @@ +Are you using the latest version of tmuxp? Check `tmuxp -V` against +https://pypi.org/project/tmuxp/. If it's not the latest, consider updating, e.g. +`pip install --user tmuxp`. + +### Step 1: Provide a summary of your problem + +- For general technical questions, problems or feature requests related to the code **in this repository** file an issue. + +### Step 2: Provide tmuxp details + +- Include output of `tmuxp debug-info` +- Include any other pertinant system info not captured + +### Step 3: Describe the problem: + +#### Steps to reproduce: + +1. ... +2. ... +3. ... + +#### Observed Results: + +- What happened? This could be a description, log output, etc. + +#### Expected Results: + +- What did you expect to happen? + +#### Relevant Code: + +``` +// TODO(you): paste here tmuxp configuration (YAML / JSON) you are having issues with. +``` diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..6246268b7a --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,30 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - enhancement +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: | + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + + This bot is used to handle issues where the issue hasn't been discussed or + has gone out of date. If an issue isn't resolved and handled in a certain + period of time, it may be closed. If you would like your issue re-opened, + please create a fresh issue with the latest, up to date information and + mention this issue in it. +unmarkComment: | + Thank you for updating this issue. It is no longer marked as stale. + +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false + +# limit to only 'issues' or 'pulls' +only: issues diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..376d1567b1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,136 @@ +name: docs + +on: + push: + branches: + - master + +permissions: + contents: read + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + environment: docs + strategy: + matrix: + python-version: ['3.14'] + steps: + - uses: actions/checkout@v6 + - name: Filter changed file paths to outputs + uses: dorny/paths-filter@v4.0.1 + id: changes + with: + filters: | + root_docs: + - CHANGES + - README.* + docs: + - 'docs/**' + - 'examples/**' + python_files: + - 'src/tmuxp/**' + - pyproject.toml + - uv.lock + + - name: Should publish + if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' + run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV + + - name: Install uv + uses: astral-sh/setup-uv@v7 + if: env.PUBLISH == 'true' + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + if: env.PUBLISH == 'true' + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + if: env.PUBLISH == 'true' + run: uv sync --all-extras --dev + + - name: Install just + if: env.PUBLISH == 'true' + uses: extractions/setup-just@v4 + + - name: Set up Node.js + if: env.PUBLISH == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + if: env.PUBLISH == 'true' + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Cache Puppeteer browser + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/puppeteer + key: puppeteer-${{ runner.os }}-${{ hashFiles('docs/pnpm-lock.yaml') }} + restore-keys: | + puppeteer-${{ runner.os }}- + + # Diagrams (sphinx-gp-mermaid) are rendered at build time by + # mmdc, which drives a headless Chrome. Without it the build still + # succeeds but degrades diagrams to a text fallback, so provision both. + - name: Install diagram toolchain (mermaid-cli + Chrome) + if: env.PUBLISH == 'true' + run: | + pnpm -C docs install --frozen-lockfile + pnpm -C docs rebuild puppeteer + pnpm -C docs exec mmdc --version + + - name: Print python versions + if: env.PUBLISH == 'true' + run: | + python -V + uv run python -V + + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + + - name: Build documentation + if: env.PUBLISH == 'true' + run: | + cd docs && just html + + - name: Configure AWS Credentials + if: env.PUBLISH == 'true' + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.TMUXP_DOCS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Push documentation to S3 + if: env.PUBLISH == 'true' + run: | + aws s3 sync docs/_build/html "s3://${{ secrets.TMUXP_DOCS_BUCKET }}" \ + --delete --follow-symlinks + + - name: Invalidate CloudFront + if: env.PUBLISH == 'true' + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ secrets.TMUXP_DOCS_DISTRIBUTION }}" \ + --paths "/index.html" "/history.html" "/objects.inv" "/searchindex.js" + + - name: Purge cache on Cloudflare + if: env.PUBLISH == 'true' + uses: jakejarvis/cloudflare-purge-action@v0.3.0 + env: + CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} + CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000000..0e937801f9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,123 @@ +name: tests + +on: [push, pull_request] + +jobs: + build: + # Don't run twice for internal PRs from our own repo + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.14'] + tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7', 'master'] + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Test runtime dependencies + run: | + uv run --no-dev -p python${{ matrix.python-version }} -- python -c ' + from tmuxp import _internal, cli, workspace, exc, log, plugin, shell, types, util, __version__ + from tmuxp._internal import config_reader, types + from tmuxp.cli import convert, debug_info, edit, freeze, import_config, load, ls, shell, utils + from tmuxp.workspace import builder, constants, finders, freezer, importers, loader, validation + from libtmux import __version__ as __libtmux_version__ + print("tmuxp version:", __version__) + print("libtmux version:", __libtmux_version__) + ' + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} + id: tmux-build-cache + uses: actions/cache@v5 + with: + path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} + key: tmux-${{ matrix.tmux-version }} + + - name: Build tmux ${{ matrix.tmux-version }} + if: steps.tmux-build-cache.outputs.cache-hit != 'true' + run: | + sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison + mkdir ~/tmux-builds + mkdir ~/tmux-src + git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }} + cd ~/tmux-src/tmux-${{ matrix.tmux-version }} + git checkout ${{ matrix.tmux-version }} + sh autogen.sh + ./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install + export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH + cd ~ + tmux -V + + - name: Lint with ruff check . + run: uv run ruff check . + + - name: Format with ruff + run: uv run ruff format . --check + + - name: Lint with mypy + run: uv run mypy . + + - name: Print python versions + run: | + python -V + uv run python -V + + - name: Test with pytest + continue-on-error: ${{ matrix.tmux-version == 'master' }} + run: | + sudo apt install libevent-2.1-7 + export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH + ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin + tmux -V + uv run py.test --cov=./ --cov-report=xml --verbose + + - uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + release: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + permissions: + id-token: write + attestations: write + + strategy: + matrix: + python-version: ['3.14'] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Build package + run: uv build + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true + skip-existing: true diff --git a/.gitignore b/.gitignore index ca208683b7..7e36db2f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -42,8 +45,9 @@ htmlcov/ .cache nosetests.xml coverage.xml -*,cover +*.cover .hypothesis/ +.pytest_cache/ # Translations *.mo @@ -55,16 +59,36 @@ coverage.xml # Sphinx documentation docs/_build/ +# Mermaid diagram tooling (sphinx-gp-mermaid) +docs/node_modules/ +docs/_mermaid_cache/ + # PyBuilder target/ -#Ipython Notebook +# Ipython Notebook .ipynb_checkpoints +# mypy +.mypy_cache/ + # editors .idea .ropeproject *.swp +.vscode # docs doc/_build/ + +# MonkeyType +monkeytype.sqlite3 + +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + +# Claude code +**/CLAUDE.local.md +**/CLAUDE.*.md +**/.claude/settings.local.json diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 8b13789179..0000000000 --- a/.gitmodules +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..de753c537d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..6324d401a0 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/.tmuxp.json b/.tmuxp.json index be8694b309..c2d1640c02 100644 --- a/.tmuxp.json +++ b/.tmuxp.json @@ -1,44 +1,41 @@ { - "before_script": "./bootstrap_env.py", + "session_name": "tmuxp", + "start_directory": "./", + "shell_command_before": [ + "uv virtualenv --quiet > /dev/null 2>&1 && clear" + ], "windows": [ { + "window_name": "tmuxp", + "focus": true, + "layout": "main-horizontal", + "options": { + "main-pane-height": "67%" + }, "panes": [ { "focus": true - }, - "pane", - "make watch_test" - ], - "options": { - "main-pane-height": 35 - }, - "layout": "main-horizontal", - "shell_command_before": [ - "[ -d .venv -a -f .venv/bin/activate ] && source .venv/bin/activate" - ], - "focus": true, - "window_name": "tmuxp" - }, + }, + "pane", + "just watch-mypy", + "just watch-test" + ] + }, { + "window_name": "docs", + "layout": "main-horizontal", + "options": { + "main-pane-height": "67%" + }, + "start_directory": "docs/", "panes": [ { "focus": true - }, - "pane", - "make serve", - "make watch" - ], - "start_directory": "doc/", - "layout": "main-horizontal", - "shell_command_before": [ - "[ -d ../.venv -a -f ../.venv/bin/activate ] && source ../.venv/bin/activate" - ], - "options": { - "main-pane-height": 35 - }, - "window_name": "docs" + }, + "pane", + "pane", + "just start" + ] } - ], - "session_name": "tmuxp", - "start_directory": "./" + ] } \ No newline at end of file diff --git a/.tmuxp.yaml b/.tmuxp.yaml index 255b5da85c..b004edf622 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -1,28 +1,25 @@ session_name: tmuxp start_directory: ./ # load session relative to config location (project root). -before_script: ./bootstrap_env.py # ./ to load relative to project root. +shell_command_before: +- uv virtualenv --quiet > /dev/null 2>&1 && clear windows: - window_name: tmuxp focus: True layout: main-horizontal options: - main-pane-height: 35 - shell_command_before: - - '[ -d .venv -a -f .venv/bin/activate ] && source .venv/bin/activate' + main-pane-height: 67% panes: - focus: true - pane - - make watch_test - + - just watch-mypy + - just watch-test - window_name: docs layout: main-horizontal options: - main-pane-height: 35 - start_directory: doc/ - shell_command_before: - - '[ -d ../.venv -a -f ../.venv/bin/activate ] && source ../.venv/bin/activate' + main-pane-height: 67% + start_directory: docs/ panes: - focus: true - pane - - make serve - - make watch + - pane + - just start diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..cc11d913c4 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +just 1.57.0 +uv 0.11.29 +python 3.14 3.13 3.12 3.11 3.10 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 581a5509b0..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -language: python - -sudo: false -python: - - 2.6 - - 2.7 - - 3.3 - - 3.4 - - 3.5 -env: - - TMUX_VERSION=master - - TMUX_VERSION=2.3 - - TMUX_VERSION=2.2 - - TMUX_VERSION=2.1 - - TMUX_VERSION=2.0 - - TMUX_VERSION=1.8 - - TMUX_VERSION=1.9a -matrix: - allow_failures: - - env: TMUX_VERSION=master -before_install: - - export PIP_USE_MIRRORS=true - - pip install --upgrade pytest # https://github.com/travis-ci/travis-ci/issues/4873 - - pip install --upgrade pip wheel virtualenv setuptools - - pip install coveralls -install: - - pip install -e . -before_script: - - git clone https://github.com/tmux/tmux.git tmux - - cd tmux - - git checkout $TMUX_VERSION - - sh autogen.sh - - ./configure --prefix=$HOME/tmux && make && make install - - export PATH=$PATH:$HOME/tmux/bin - - cd .. - - tmux -V -script: coverage run --source=tmuxp setup.py test -addons: - apt: - packages: - - libevent-dev - - libncurses-dev -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json new file mode 100644 index 0000000000..d542a72629 --- /dev/null +++ b/.vim/coc-settings.json @@ -0,0 +1,19 @@ +{ + "[markdown][python]": { + "coc.preferences.formatOnSave": true + }, + "python.analysis.autoSearchPaths": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.useLibraryCodeForTypes": true, + "python.formatting.provider": "ruff", + "python.linting.ruffEnabled": true, + "python.linting.mypyEnabled": true, + "python.linting.flake8Enabled": false, + "python.linting.pyflakesEnabled": false, + "python.linting.pycodestyleEnabled": false, + "python.linting.banditEnabled": false, + "python.linting.pylamaEnabled": false, + "python.linting.pylintEnabled": false, + "pyright.organizeimports.provider": "ruff", + "pyright.testing.provider": "pytest", +} diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000000..80360fcb75 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,170 @@ +# libtmux Python Project Rules + + +- uv - Python package management and virtual environments +- ruff - Fast Python linter and formatter +- py.test - Testing framework + - pytest-watcher - Continuous test runner +- mypy - Static type checking +- doctest - Testing code examples in documentation + + + +- Use a consistent coding style throughout the project +- Format code with ruff before committing +- Run linting and type checking before finalizing changes +- Verify tests pass after each significant change + + + +- Use reStructuredText format for all docstrings in src/**/*.py files +- Keep the main description on the first line after the opening `"""` +- Use NumPy docstyle for parameter and return value documentation +- Format docstrings as follows: + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + + + +- Use narrative descriptions for test sections rather than inline comments +- Format doctests as follows: + ```python + """ + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` +- Add blank lines between test sections for improved readability +- Keep doctests simple and focused on demonstrating usage +- Move complex examples to dedicated test files at tests/examples//test_.py +- Utilize pytest fixtures via doctest_namespace for complex scenarios + + + +- Run tests with `uv run py.test` before committing changes +- Use pytest-watcher for continuous testing: `uv run ptw . --now --doctest-modules` +- Fix any test failures before proceeding with additional changes + + + +- Make atomic commits with conventional commit messages +- Start with an initial commit of functional changes +- Follow with separate commits for formatting, linting, and type checking fixes + + + +- Use the following commit message format: + ``` + Component/File(commit-type[Subcomponent/method]): Concise description + + why: Explanation of necessity or impact. + what: + - Specific technical changes made + - Focused on a single topic + + refs: #issue-number, breaking changes, or relevant links + ``` + +- Common commit types: + - **feat**: New features or enhancements + - **fix**: Bug fixes + - **refactor**: Code restructuring without functional change + - **docs**: Documentation updates + - **chore**: Maintenance (dependencies, tooling, config) + - **test**: Test-related updates + - **style**: Code style and formatting + +- Prefix Python package changes with: + - `py(deps):` for standard packages + - `py(deps[dev]):` for development packages + - `py(deps[extra]):` for extras/sub-packages + +- General guidelines: + - Subject line: Maximum 50 characters + - Body lines: Maximum 72 characters + - Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") + - Limit to one topic per commit + - Separate subject from body with a blank line + - Mark breaking changes clearly: `BREAKING:` + + + +- Use fixtures from conftest.py instead of monkeypatch and MagicMock when available +- For libtmux tests, use these provided fixtures for fast, efficient tmux resource management: + - `server`: Creates a temporary tmux server with isolated socket + - `session`: Creates a temporary tmux session in the server + - `window`: Creates a temporary tmux window in the session + - `pane`: Creates a temporary tmux pane in the pane + - `TestServer`: Factory for creating multiple independent servers with unique socket names +- Example usage with server fixture: + ```python + def test_something_with_server(server): + # server is already running with proper configuration + my_session = server.new_session("test-session") + assert server.is_alive() + ``` +- Example usage with session fixture: + ```python + def test_something_with_session(session): + # session is already created and configured + new_window = session.new_window("test-window") + assert new_window in session.windows + ``` +- Customize session parameters by overriding the session_params fixture: + ```python + @pytest.fixture + def session_params(): + return { + 'x': 800, + 'y': 600, + 'window_name': 'custom-window' + } + ``` +- Benefits of using libtmux fixtures: + - No need to manually set up and tear down tmux infrastructure + - Tests run in isolated tmux environments + - Faster test execution + - Reliable test environment with predictable configuration +- Document in test docstrings why standard fixtures weren't used for exceptional cases +- Use tmp_path (pathlib.Path) fixture over Python's tempfile +- Use monkeypatch fixture over unittest.mock + + + +- Prefer namespace imports over importing specific symbols +- Import modules and access attributes through the namespace: + - Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like pathlib, os, and similar cases +- For typing, use `import typing as t` and access via the namespace: + - Access typing elements as `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes + - Primitive types like list and dict can be done via `list` and `dict` directly +- Benefits of namespace imports: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..3e0a275948 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,645 @@ +# AGENTS.md + +This file provides guidance to AI agents (e.g., Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. + +## Project Overview + +tmuxp is a session manager for tmux that allows users to save and load tmux sessions through YAML/JSON configuration files. It's powered by libtmux and provides a declarative way to manage tmux sessions. + +## Development Commands + +### Testing +- `just test` or `uv run py.test` - Run all tests +- `uv run py.test tests/path/to/test.py::TestClass::test_method` - Run a single test +- `uv run ptw .` - Continuous test runner with pytest-watcher +- `uv run ptw . --now --doctest-modules` - Watch tests including doctests +- `just start` or `just watch-test` - Watch and run tests on file changes + +### Code Quality +- `just ruff` or `uv run ruff check .` - Run linter +- `uv run ruff check . --fix --show-fixes` - Fix linting issues automatically +- `just ruff-format` or `uv run ruff format .` - Format code +- `just mypy` or `uv run mypy` - Run type checking (strict mode enabled) +- `just watch-ruff` - Watch and lint on changes +- `just watch-mypy` - Watch and type check on changes + +### Documentation +- `just build-docs` - Build documentation +- `just serve-docs` - Serve docs locally at http://localhost:8013 +- `just dev-docs` - Watch and serve docs with auto-reload +- `just start-docs` - Alternative to dev_docs + +### CLI Commands +- `tmuxp load ` - Load a tmux session from config +- `tmuxp load -d ` - Load session in detached state +- `tmuxp freeze ` - Export running session to config +- `tmuxp convert ` - Convert between YAML and JSON +- `tmuxp shell` - Interactive Python shell with tmux context +- `tmuxp debug-info` - Collect system info for debugging + +## Architecture + +### Core Components + +1. **CLI Module** (`src/tmuxp/cli/`): Entry points for all tmuxp commands + - `load.py`: Load tmux sessions from config files + - `freeze.py`: Export live sessions to config files + - `convert.py`: Convert between YAML/JSON formats + - `shell.py`: Interactive Python shell with tmux context + +2. **Workspace Module** (`src/tmuxp/workspace/`): Core session management + - `builder.py`: Builds tmux sessions from configuration + - `loader.py`: Loads and validates config files + - `finders.py`: Locates workspace config files + - `freezer.py`: Exports running sessions to config + +3. **Plugin System** (`src/tmuxp/plugin.py`): Extensibility framework + - Plugins extend `TmuxpPlugin` base class + - Hooks: `before_workspace_builder`, `on_window_create`, `after_window_finished`, `before_script`, `reattach` + - Version constraint checking for compatibility + +### Configuration Flow + +1. Load YAML/JSON config via `ConfigReader` (handles includes, environment variables) +2. Expand inline shorthand syntax +3. Trickle down default values (session → window → pane) +4. Validate configuration structure +5. Build tmux session via `WorkspaceBuilder` + +### Key Patterns + +- **Type Safety**: All code uses type hints with mypy strict mode +- **Error Handling**: Custom exception hierarchy based on `TmuxpException` +- **Testing**: Pytest with fixtures for tmux server/session/window/pane isolation +- **Future Imports**: All files use `from __future__ import annotations` + +## Configuration Format + +```yaml +session_name: my-session +start_directory: ~/project +windows: + - window_name: editor + layout: main-vertical + panes: + - shell_command: + - vim + - shell_command: + - git status +``` + +## Environment Variables + +- `TMUXP_CONFIGDIR`: Custom directory for workspace configs +- `TMUX_CONF`: Path to tmux configuration file +- `TMUXP_DEFAULT_COLUMNS/ROWS`: Default session dimensions + +## Testing Guidelines + +- **Use functional tests only**: Write tests as standalone functions, not classes. Avoid `class TestFoo:` groupings - use descriptive function names and file organization instead. +- Use pytest fixtures from `tests/fixtures/` for tmux objects +- Test plugins using mock packages in `tests/fixtures/pluginsystem/` +- Use `retry_until` utilities for async tmux operations +- Run single tests with: `uv run py.test tests/file.py::test_function_name` +- **Use libtmux fixtures**: Prefer `server`, `session`, `window`, `pane` fixtures over manual setup +- **Avoid mocks when fixtures exist**: Use real tmux fixtures instead of `MagicMock` +- **Use `tmp_path`** fixture instead of Python's `tempfile` +- **Use `monkeypatch`** fixture instead of `unittest.mock` + +## Code Style + +- Follow NumPy-style docstrings (pydocstyle convention) +- Use ruff for formatting and linting +- Maintain strict mypy type checking +- Keep imports organized with future annotations at top +- **Prefer namespace imports for stdlib**: Use `import enum` and `enum.Enum` instead of `from enum import Enum`; third-party packages may use `from X import Y` +- **Type imports**: Use `import typing as t` and access via namespace (e.g., `t.Optional`) +- **Development workflow**: Format → Test → Commit → Lint/Type Check → Test → Final Commit + +## Git Commit Standards + +Format commit messages as: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. + +what: +- Specific technical changes made +- Focused on a single topic +``` + +Keep the subject ≤50 chars (excluding any trailing `(#NN)` PR ref); wrap +body lines at ≤72 chars. Separate the `why:` and `what:` blocks with a +blank line. + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) +- **ai(claude[command])**: Claude Code command changes + +Example: +``` +Pane(feat[send_keys]): Add support for literal flag + +why: Enable sending literal characters without tmux interpretation + +what: +- Add literal parameter to send_keys method +- Update send_keys to pass -l flag when literal=True +- Add tests for literal key sending +``` +#### Release commits + +Never create tags. Never push tags. The user handles tagging and tag +pushes (tags trigger the CI publish workflow). + +Release commit subjects are plain and short: `Tag v`. Put +the detailed why/what in the commit body. Don't use the +`Scope(type[detail]):` format for releases — don't bury the lede. + +For multi-line commits, use heredoc to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +feat(Component[method]) add feature description + +why: Explanation of the change. + +what: +- First change +- Second change +EOF +)" +``` + +## Logging Standards + +These rules guide future logging changes; existing code may not yet conform. + +### Logger setup + +- Use `logging.getLogger(__name__)` in every module +- Add `NullHandler` in library `__init__.py` files +- Never configure handlers, levels, or formatters in library code — that's the application's job + +### Structured context via `extra` + +Pass structured data on every log call where useful for filtering, searching, or test assertions. + +**Core keys** (stable, scalar, safe at any log level): + +| Key | Type | Context | +|-----|------|---------| +| `tmux_cmd` | `str` | tmux command line | +| `tmux_subcommand` | `str` | tmux subcommand (e.g. `new-session`) | +| `tmux_target` | `str` | tmux target specifier (e.g. `mysession:1.2`) | +| `tmux_exit_code` | `int` | tmux process exit code | +| `tmux_session` | `str` | session name | +| `tmux_window` | `str` | window name or index | +| `tmux_pane` | `str` | pane identifier | +| `tmux_config_path` | `str` | workspace config file path | +| `tmux_layout` | `str` | window layout string | + +**Heavy/optional keys** (DEBUG only, potentially large): + +| Key | Type | Context | +|-----|------|---------| +| `tmux_stdout` | `list[str]` | tmux stdout lines (truncate or cap; `%(tmux_stdout)s` produces repr) | +| `tmux_stderr` | `list[str]` | tmux stderr lines (same caveats) | + +Treat established keys as compatibility-sensitive — downstream users may build dashboards and alerts on them. Change deliberately. + +### Key naming rules + +- `snake_case`, not dotted; `tmux_` prefix +- Prefer stable scalars; avoid ad-hoc objects +- Heavy keys (`tmux_stdout`, `tmux_stderr`) are DEBUG-only; consider companion `tmux_stdout_len` fields or hard truncation (e.g. `stdout[:100]`) + +### Lazy formatting + +`logger.debug("msg %s", val)` not f-strings. Two rationales: +- Deferred string interpolation: skipped entirely when level is filtered +- Aggregator message template grouping: `"Running %s"` is one signature grouped ×10,000; f-strings make each line unique + +When computing `val` itself is expensive, guard with `if logger.isEnabledFor(logging.DEBUG)`. + +### stacklevel for wrappers + +Increment for each wrapper layer so `%(filename)s:%(lineno)d` and OTel `code.filepath` point to the real caller. Verify whenever call depth changes. + +### LoggerAdapter for persistent context + +For objects with stable identity (Session, Window, Pane), use `LoggerAdapter` to avoid repeating the same `extra` on every call. Lead with the portable pattern (override `process()` to merge); `merge_extra=True` simplifies this on Python 3.13+. + +### Log levels + +| Level | Use for | Examples | +|-------|---------|----------| +| `DEBUG` | Internal mechanics, tmux I/O, config expansion | tmux command + stdout, trickle-down steps | +| `INFO` | Session lifecycle, user-visible operations | Session created, window added, workspace loaded | +| `WARNING` | Recoverable issues, deprecation, user-actionable config | Deprecated key, missing optional program | +| `ERROR` | Failures that stop an operation | tmux command failed, config validation error | + +Config discovery noise belongs in `DEBUG`; only surprising/user-actionable config issues → `WARNING`. + +### Message style + +- Lowercase, past tense for events: `"session created"`, `"tmux command failed"` +- No trailing punctuation +- Keep messages short; put details in `extra`, not the message string + +### Exception logging + +- Use `logger.exception()` only inside `except` blocks when you are **not** re-raising +- Use `logger.error(..., exc_info=True)` when you need the traceback outside an `except` block +- Avoid `logger.exception()` followed by `raise` — this duplicates the traceback. Either add context via `extra` that would otherwise be lost, or let the exception propagate + +### Testing logs + +Assert on `caplog.records` attributes, not string matching on `caplog.text`: +- Scope capture: `caplog.at_level(logging.DEBUG, logger="libtmux.common")` +- Filter records rather than index by position: `[r for r in caplog.records if hasattr(r, "tmux_cmd")]` +- Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text` +- `caplog.record_tuples` cannot access extra fields — always use `caplog.records` + +### Output channels + +Two output channels serve different audiences: + +1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled. +2. **User-facing output**: What the human sees. Styled via `Colors` class. + - Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes. + - Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages. + - **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes. + +Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`. + +### Avoid + +- f-strings/`.format()` in log calls +- Unguarded logging in hot loops (guard with `isEnabledFor()`) +- Catch-log-reraise without adding new context +- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead +- Logging secret env var values (log key names only) +- Non-scalar ad-hoc objects in `extra` +- Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) + +## Doctests + +**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests. + +**CRITICAL RULES:** +- Doctests MUST actually execute - never comment out function calls or similar +- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run) +- If you cannot create a working doctest, **STOP and ask for help** + +**Available tools for doctests:** +- `doctest_namespace` fixtures: `server`, `session`, `window`, `pane`, `tmp_path`, `test_utils` +- Ellipsis for variable output: `# doctest: +ELLIPSIS` +- Update `conftest.py` to add new fixtures to `doctest_namespace` + +**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. Use the fixtures properly - tmux is required to run tests anyway. + +**Using fixtures in doctests:** +```python +>>> from tmuxp.workspace.builder import WorkspaceBuilder +>>> config = {'session_name': 'test', 'windows': [{'window_name': 'main'}]} +>>> builder = WorkspaceBuilder(session_config=config, server=server) # doctest: +ELLIPSIS +>>> builder.build() +>>> builder.session.name +'test' +``` + +**When output varies, use ellipsis:** +```python +>>> session.session_id # doctest: +ELLIPSIS +'$...' +>>> window.window_id # doctest: +ELLIPSIS +'@...' +``` + +**Additional guidelines:** +1. **Use narrative descriptions** for test sections rather than inline comments +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` +3. **Keep doctests simple and focused** on demonstrating usage +4. **Add blank lines between test sections** for improved readability + +**Doctest exceptions** (patterns where doctests are not required): + +1. **Sphinx/docutils `visit_*`/`depart_*` methods** - tested via integration tests; 0 examples across docutils (851 methods), Sphinx (800+), and CPython's `ast.NodeVisitor` +2. **Sphinx `setup()` functions** - entry points not testable in isolation +3. **Complex recursive traversal functions** - extract helper predicates instead + +**Best practice for node processing**: Extract testable helper functions (like `_is_usage_block()`) and doctest those. Keep complex visitor logic in integration tests. + +## Documentation Standards + +### Code Blocks in Documentation + +When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: + +**One command per code block.** This makes commands individually copyable. For sequential commands, either use separate code blocks or chain them with `&&` or `;` and `\` continuations (keeping it one logical command). + +**Put explanations outside the code block**, not as comments inside. + +Good: + +Run the tests: + +```console +$ uv run pytest +``` + +Run with coverage: + +```console +$ uv run pytest --cov +``` + +Bad: + +```console +# Run the tests +$ uv run pytest + +# Run with coverage +$ uv run pytest --cov +``` + +### Shell Command Formatting + +These rules apply to shell commands in documentation (README, CHANGES, docs/), **not** to Python doctests. + +**Use `console` language tag with `$ ` prefix.** This distinguishes interactive commands from scripts and enables prompt-aware copy in many terminals. + +Good: + +```console +$ uv run pytest +``` + +Bad: + +```bash +uv run pytest +``` + +**Split long commands with `\` for readability.** Each flag or flag+value pair gets its own continuation line, indented. Positional parameters go on the final line. + +Good: + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Bad: + +```console +$ pipx install --suffix=@next --pip-args '\--pre' --force 'tmuxp' +``` + +### Changelog Conventions + +These rules apply when authoring entries in `CHANGES`, which is rendered as the Sphinx changelog page. Modeled on Django's release-notes shape — deliverables get titles and prose, not bullets. + +**Release entry boilerplate.** Every release header is `## tmuxp X.Y.Z (YYYY-MM-DD)`. The file opens with a `## tmuxp X.Y.Z (Yet to be released)` placeholder block fenced by `` and `` HTML comments — new release entries land immediately below the END marker, never above it. + +**Open with a multi-sentence lead paragraph.** Plain prose, no italic. Open with the version as sentence subject (*"tmuxp X.Y.Z ships …"*) so the lead is self-contained when excerpted. Two to four sentences telling the reader what shipped and who cares — user-visible takeaways, not internal mechanism. Cross-reference detail docs with `{ref}` to keep the lead compact. + +**Each deliverable is a section, not a bullet.** Inside `### What's new`, every distinct deliverable gets a `#### Deliverable title (#NN)` heading naming it in user vocabulary, followed by 1-3 prose paragraphs explaining what shipped. Don't wrap a paragraph in `- ` — bullets are for enumerable lists, not paragraph containers. Cross-link detail docs (`See {ref}\`foo\` for details.`) so prose stays focused. + +**The deliverable test.** Before writing an entry, ask: "What's the deliverable, in user vocabulary?" If you can't answer in one sentence, the entry isn't ready. Mechanism (helper internals, byte counters, schema-validation locations) belongs in PR descriptions and code comments, not the changelog. + +**Fixed subheadings**, in this order when present: `### Breaking changes`, `### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, `### Development`. Dev tooling (helper scripts, internal automation) lives under `### Development`. For breaking changes, show the migration path with concrete inline code (e.g. a `# Before` / `# After` fenced code block). Dependency floor bumps use the form ``Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`)``. + +**PR refs `(#NN)`** sit in each deliverable's `####` heading. + +**When bullets are appropriate.** Catch-all sections (`### Fixes`, occasionally `### Documentation`) with 3+ genuinely small items use bullets — one line each, never paragraphs. If a bullet swells past two lines, promote it to a `#### Title (#NN)` heading with prose body. + +**Anti-patterns.** + +- Fragile metrics: token ceilings, third-party version pins, percent benchmarks, exact byte counts. Describe the *capability*, not the math. +- Internal jargon: private symbols (leading-underscore identifiers), algorithm names exposed for the first time, backend scaffolding. +- Walls of text dressed up as bullets. +- Buried breaking changes — they get their own subheading at the top of the entry. + +**Always link autodoc'd APIs.** Any class, method, function, exception, or attribute that has its own rendered page must be cited via the appropriate role (`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`) — never with plain backticks. Doc pages without explicit ref labels use `{doc}`. Plain backticks are correct for code syntax, env vars, parameter names, and file paths that aren't doc pages — anything without an autodoc destination. + +**MyST roles.** Class references use `{class}` (e.g. `{class}\`~tmuxp.workspace.builder.WorkspaceBuilder\``), methods use `{meth}`, functions use `{func}`, exceptions use `{exc}`, attributes use `{attr}`, internal anchors use `{ref}`, doc-path links use `{doc}`. + +**Summarization style.** When a user asks "what changed in the latest version?" or similar, lead with the entry's lead paragraph (paraphrased if needed), followed by each `####` deliverable heading under `### What's new` with a one-sentence summary. Cite `(#NN)` only if the user asks for source links. Don't invent versions, dates, or numbers not present in `CHANGES`. Don't quote line numbers or file offsets — those shift as the file evolves. + +## Important Notes + +- **QA every edit**: Run formatting and tests before committing +- **Minimum Python**: 3.10+ (per pyproject.toml) +- **Minimum tmux**: 3.2+ (as per README) + +## CLI Color Semantics (Revision 1, 2026-01-04) + +The CLI uses semantic colors via the `Colors` class in `src/tmuxp/_internal/colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. + +### Design Principles + +1. **Structural hierarchy**: Headers > Items > Details +2. **Semantic meaning**: What IS this element? +3. **Visual weight**: What should draw the eye first? +4. **Depth separation**: Parent elements should visually contain children + +Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line/match distinction), and **mise/just** (semantic method names). + +### Hierarchy-Based Colors + +| Level | Element Type | Method | Color | Examples | +|-------|--------------|--------|-------|----------| +| **L0** | Section headers | `heading()` | Bright cyan + bold | "Local workspaces:", "Global workspaces:" | +| **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | +| **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | +| **L3** | Metadata/labels | `muted()` | Blue | Source labels (Legacy:, XDG default:) | + +### Status-Based Colors (Override hierarchy when applicable) + +| Status | Method | Color | Examples | +|--------|--------|-------|----------| +| Success/Active | `success()` | Green | "active", "18 workspaces" | +| Warning | `warning()` | Yellow | Deprecation notices | +| Error | `error()` | Red | Error messages | + +### Example Output + +``` +Local workspaces: ← heading() bright_cyan+bold + .tmuxp ~/work/python/tmuxp/.tmuxp.yaml ← highlight() + info() + +Global workspaces (~/.tmuxp): ← heading() + info() + braintree ← highlight() + cihai ← highlight() + +Global workspace directories: ← heading() + Legacy: ~/.tmuxp (18 workspaces, active) ← muted() + info() + success() + XDG default: ~/.config/tmuxp (not found) ← muted() + info() + muted() +``` + +### Available Methods + +```python +colors = Colors() +colors.heading("Section:") # Cyan + bold (section headers) +colors.highlight("item") # Magenta + bold (primary content) +colors.info("/path/to/file") # Cyan (paths, supplementary info) +colors.muted("label:") # Blue (metadata, labels) +colors.success("ok") # Green (success states) +colors.warning("caution") # Yellow (warnings) +colors.error("failed") # Red (errors) +``` + +### Key Rules + +**Never use the same color for adjacent hierarchy levels.** If headers and items are both blue, they blend together. Each level must be visually distinct. + +**Avoid dim/faint styling.** The ANSI dim attribute (`\x1b[2m`) is too dark to read on black terminal backgrounds. This includes both standard and bright color variants with dim. + +**Bold may not render distinctly.** Some terminal/font combinations don't differentiate bold from normal weight. Don't rely on bold alone for visual distinction - pair it with color differences. + +## AI Slop Prevention + +Treat AI slop as **review-hostile noise**, not as proof that text or +code is wrong. The goal is to maximize information density by removing +artifacts that make the repository harder to trust or navigate. + +### The Anti-Slop Rubric + +Before committing, audit all AI-assisted changes for these noise +patterns: + +- **AI Signatures:** Remove "Generated by", footers, conversational + filler ("Certainly!", "Here is..."), unexplained emojis (🤖, ✨), and + AI-tool metadata. +- **Brittle References:** Avoid hard-coded line numbers, fragile + file/test counts, dated "as of" claims, bare SHAs, and local + absolute paths unless they are strict evidentiary artifacts (e.g., + benchmark logs). +- **Diff Narration:** Do not restate what moved, was renamed, or was + removed in artifacts the downstream reader holds: code, docstrings, + README, CHANGES, PR descriptions, or release notes. The diff and + commit message already carry this history. +- **Branch-Internal Narrative:** Do not mention intermediate branch + states, abandoned approaches, or "no longer" behavior unless users + of a published release actually experienced the old state (**The + Published-Release Test**). +- **Low-Value Scaffolding:** Remove ownerless TODOs (`TODO: revisit`), + unused future-proofing, debug artifacts, and defensive wrappers that + do not protect a currently reachable failure mode. +- **Prose Inflation:** Replace generic AI "tells" like *comprehensive, + robust, seamless, production-ready, leverage, delve, tapestry,* and + *best practices* with concrete descriptions of behavior, + constraints, or trade-offs. +- **Coded Labels:** Write rules, options, and findings as plain + imperatives. Don't tag them with codes like `[R1]`, `A1`, or + `Option B` in artifacts a human reads — the reader shouldn't have to + decode an index. Internal agent bookkeeping may use ids; shipped text + may not. + +### Durable Source Links + +Link to a pinned revision, never to trunk. A pinned permalink is not a +brittle reference; an unlinked SHA dropped into prose is. `blob/master/…` +links rot silently — the file moves, lines shift, and the anchor lands +on unrelated code while still resolving. + +- Prefer a release tag (`blob/v1.4.0/…`). Most durable, and it tells + the reader which released version the claim held for. +- Otherwise use a 7-char commit ref (`blob/9a29b1a/…`) reachable from + trunk. Use when there is no tag or the claim is about unreleased + code. Never a PR-head SHA — it can be rebased or garbage-collected. +- Reserve `blob/master/…` for living documents meant to always show the + latest state, such as a contributing guide. +- Line anchors (`#L120-L145`) are only safe on a pinned ref. + +### Preservation & Context + +**When unsure, leave the text in place and ask.** Subjective cleanup +must never be a reason to remove load-bearing rationale. + +- **Preserve the "Why":** You MUST NOT delete comments that document + invariants, protocol constraints, platform quirks, security + boundaries, and upstream workarounds. +- **Evidence is Immune:** Preserve exact counts, dates, and SHAs when + they serve as evidence in benchmark results, release notes, stack + traces, or lockfiles. +- **Behavior Over Inventory:** A useful description explains what + changed for the *system or user*; it does not provide an inventory + of files or functions the diff already shows. + +### The Published-Release Test + +Long-running branches accumulate tactical decisions — renames, +refactors, attempts-then-reverts. When deciding what counts as +branch-internal, use trunk or the parent branch as the baseline — not +intermediate states inside the current branch. Ask: + +> Did users of the most recently published release ever experience +> this old name, old behavior, or bug? + +If the answer is **no**, it is branch-internal narrative. Move it to +the commit message and describe only the final state in the artifact. + +**Keep in shipped artifacts:** +- Deprecations and migration guides for symbols that actually shipped. +- `### Fixes` entries for bugs that affected users of a published + release. +- Comments explaining *why the current code looks this way* + (invariants, platform quirks) that make sense to a reader who never + saw the previous version. + +### Cleanup in Hindsight + +When applying these rules retroactively from inside a feature branch, +first establish scope by diffing against the parent branch (or trunk) +to identify which commits this branch actually introduced. Then: + +- **In-branch commits:** Prompt the user with two options: `fixup!` + commits with `git rebase --autosquash` to address each causal commit + at its source, or a single cleanup commit at branch tip. +- **Trunk/Parent commits:** Default to leaving them alone. Act only on + explicit user instruction. If the user opts in, fold the cleanup + into a single commit at branch tip; do not rewrite shared history. +- **Scope guard:** If cleaning prior slop would touch a colleague's + work or expand the branch beyond its stated goal, stay in lane: + protect the current goal and leave prior slop alone. + +### Change Discipline + +- Make the smallest coherent change that solves the verified problem; + keep unrelated cleanup out of it. +- Reuse an existing file, component, helper, API, or test before adding + a new one. Modify in place when the change fits the file's + responsibility. +- Keep new APIs private until a caller outside the module needs them. +- Add a file only for a durable boundary — a distinct responsibility, + independent reuse, or splitting an oversized high-touch module — not + for a single-use helper or a one-line re-export. + +### Keep Instructions Lean + +Treat this file like code and prune it. + +- Delete a line whose removal would not cause a mistake. +- Move multi-step procedures into skills, path-specific rules into + nested AGENTS.md files, and hard limits into hooks or CI. +- Keep only non-obvious, broadly applicable defaults here. Anything a + reader can infer from the code, a manifest, or a linter does not + belong. diff --git a/CHANGES b/CHANGES index 70aeb4a828..0b2d4fe37f 100644 --- a/CHANGES +++ b/CHANGES @@ -1,687 +1,3057 @@ -========= -Changelog -========= - -Here you can find the recent changes to tmuxp - -- :release:`1.2.3 <2016-12-21>` -- :support:`-` bump libtmux 0.6.0 to 0.6.1 -- :support:`193` improve suppress history test, courtesy of @abeyer. -- :support:`191` documentation typo from @modille -- :support:`186` documentation typo from @joelwallis - -- :release:`1.2.2 <2016-09-16>` -- :bug:`181` Support tmux 2.3 - -- :release:`1.2.1 <2016-09-16>` -- :bug:`132` Handle cases with invalid session names -- :support:`- backported` update libtmux from 0.5.0 to 0.6.0 - -- :release:`1.2.0 <2016-06-16>` -- :feature:`65` Ability to specify ``options`` and ``global_options`` via - configuration. Also you can specify environment variables via that. - - Include tests and add example. - -- :release:`1.1.1 <2016-06-02>` -- :bug:`167 backported` fix attaching multiple sessions -- :bug:`165 backported` fix typo in error output, thanks @fpietka -- :support:`166 backported` add new docs on zsh/bash completion -- :feature:`- backported` Add back ``tmuxp -V`` for version info - -- :release:`1.1.0 <2016-06-01>` -- :feature:`160` load tmuxp configs by name -- :feature:`134` Use ``click`` for command-line completion, Rewrite command - line functionality for importing, config finding, conversion and prompts. -- :support:`-` Remove ``-l`` from ``tmuxp import tmuxinator|teamocil`` -- :bug:`158 major` argparse bug overcome by switch to click - - -- :release:`1.0.2 <2016-05-25>` -- :support:`163 backported` fix issue re-attaching sessions that are already loaded -- :feature:`159 backported` improved support for tmuxinator imports, from @fpietka. -- :support:`161 backported` readme link fixes from @Omeryl. - -- :release:`1.0.1 <2016-05-25>` -- :support:`- backported` switch to readthedocs.io for docs -- :support:`157 backported` bump libtmux to 0.4.1 - -- :release:`1.0.0-rc1 <2016-05-25>` -- :support:`-` version jump 0.11.1 to 1.0 -- :feature:`0 major` tests moved to py.test framework -- :feature:`0 major` `libtmux`_ core split into its own project -- :feature:`145` Add new-window command functionality, @ikirudennis -- :feature:`146` Optionally disable shell history suppression, @kmactavish -- :support:`147` Patching unittest timing for shell history suppression -- :support:`-` move doc building, tests and watcher to Makefile -- :support:`-` update .tmuxp.yaml and .tmuxp.json for Makefile change -- :support:`-` overhaul README - -- :release:`0.11.0 <2016-02-29>` -- :support:`137` Support for environment settings in configs, thanks - `@tasdomas` -- :support:`-` Spelling correction, thanks `@sehe`_. - -- :release:`0.10.0 <2016-01-30>` -- :support:`135` Load multiple tmux sessions at once, thanks `@madprog`_. -- :support:`131` :support:`133` README and Documentation fixes - -- :release:`0.9.3 <2016-01-06>` -- :support:`-` switch to ``.venv`` for virtualenv directory to not conflict - with ``.env`` (used by `autoenv`_). -- :support:`130` move to `entr(1)`_ for file watching in tests. update docs. -- [compatibility] Support `Anaconda Python`_ 2 and 3 - -- :release:`0.9.2 <2015-10-21>` -- :support:`122` Update to support tmux 2.1, thank you `@estin`_. -- :support:`-` use travis container infrastructure for faster tests -- :support:`-` change test in workspace builder test to use ``top(1)`` instead of - ``man(1)``. ``man(1)`` caused errors on some systems where ``PAGER`` is set. - -- :release:`0.9.1 <2015-08-23>` -- :support:`119` Add fix python 3 for `sysutils/pytmuxp`_ on FreeBSD ports. - See GH issue 119 and `#201564`_ @ FreeBSD Bugzilla. Thanks Ruslan - Makhmatkhanov. - -- :release:`0.9.0 <2015-07-08>` -- :support:`-` Renamed ``config.expandpath`` to ``config.expandshell``. -- :support:`-` compat 2.7/3.3 wrapper for ``EnvironmentVarGuard`` for - testing. -- :feature:`-` You can now use environment variables inside of - ``start_directory``, ``before_script``, ``shell_command_before``, - ``session_name`` and ``window_name``. -- :support:`-` [examples]: add example for environmental variables, - ``examples/env-variables.json`` and ``examples/env-variables.yaml``. -- :support:`110` de-vendorize `colorama`_. Thanks `@marbu`_. -- :support:`109` fix failure of test_pane_order on fedora machines from - `@marbu`_ -- :support:`105` append ``.txt`` extension to manuals (repo ony) - from `@yegortimoshenko`_. -- :support:`107` Fix Server.attached_sessions return type by - `@thomasballinger`_. -- :support:`-` update travis to use new tmux git repository. - -- :release:`0.8.1 <2015-05-09>` -- :support:`-` [testing]: fix sniffer test runner in python 3 -- :support:`-` new animated image demo for RTD and README - -- :release:`0.8.0 <2015-05-07>` -- :support:`-` version bump 0.1.13 -> 0.8.0 -- :support:`-` tmux 2.0 support -- :support:`-` Fix documentation for :meth:``Session.switch_client()``. -- :support:`-` Add ``--log-level`` argument. -- :support:`-` Refactor ``{Server,Session,Window,Pane}.tmux`` into: - - - :meth:`Server.cmd()` - - :meth:`Session.cmd()` - - :meth:`Window.cmd()` - - :meth:`Pane.cmd()` - - (See conversation at https://github.com/bitprophet/dotfiles/issues/5) -- :support:`-` Refactor ``util.tmux`` into :meth:`util.tmux_cmd`. - -- :release:`0.1.13 <2015-03-25>` -- :support:`-` Remove ``package_metadata.py`` in favor of ``__about__.py``. -- :support:`-` ``scent.py`` for building docs -- :support:`-` docutils from 0.11 to 0.12 -- :support:`-` ``bootstrap_env.py`` will check for linux, darwin (OS X) and - windows and install the correct `sniffer`_ file watcher plugin. -- :support:`-` testsuite for cli uses :py:func:`tempfile.mkdtemp()` instead - ``TMP_DIR`` (which resolved to ``.tmuxp`` in the testsuite directory. -- :support:`-` replace `watchingtestrunner`_ with `sniffer`_ in examples. - ``.tmuxp.conf`` and ``.tmux.json`` updated -- :support:`-` updates to doc links -- :support:`-` ``make checkbuild`` for verifying internal / intersphinx doc - references. -- :support:`-` Add Warning tmux versions less than 1.4 from `@techtonik`_. -- :support:`-` Add documentation on leading space in ``send_keys`` - from `@thomasballinger`_. -- :support:`-` Update about page from teamocil and erb support from `@raine`_. - -- :release:`0.1.12 <2014-08-06>` -- [config] :meth:`config.expand` now resolves directories in configuration - via :py:func:`os.path.expanduser` and :py:func:`os.path.expandvars`. -- [config] :meth:`config.expandpath` for helping resolve paths. -- :support:`-` :support:`-` improved support for loading tmuxp project files from - outside current working directory. e.g. - - .. code-block:: bash - - $ tmuxp load /path/to/my/project/.tmuxp.yaml - - Will behave better with relative directories. - -- :release:`0.1.11 <2014-04-06>` -- :feature:`-` ``before_script`` now loads relative to project directory with - ``./``. -- :support:`-` Use ``bootstrap_env.py`` in tmuxp's ``.tmuxp.yaml`` and - ``.tmuxp.json`` project files. -- :support:`-` Improvements to :meth:`util.run_before_script()`, - :class:`exc.BeforeLoadScriptFailed` behavior to print ``stdout`` and - return ``stderr`` is a non-zero exit is returned. -- :support:`-` ``run_script_before`` has moved to ``util``. -- :support:`-` ``BeforeLoadScriptFailed`` and ``BeforeLoadScriptNotExists`` - has moved to the ``exc`` module. -- :support:`-` Tests for ``run_script_before`` refactored. - -- :release:`0.1.10 <2014-04-02>` -- 2 bug fixes and allow panes with no shell commands to accept options, - thanks for these 3 patches, `@ThiefMaster`_: -- :bug:`73` Fix an error caused by spaces in - ``start_directory``. -- :bug:`77` Fix bug where having a ``-`` in a - ``shell_command`` would cauesd a build error. -- :bug:`76` Don't require ``shell_command`` to - pass options to panes (like ``focus: true``). - -- :release:`0.1.9 <2014-04-01>` -- The ``--force`` was not with us. - -- :release:`0.1.8 <2014-03-30>` -- :support:`72` Create destination directory if it doesn't exist. Thanks - `@ThiefMaster`_. -- :support:`-` New context manager for tests, ``temp_session``. -- :support:`-` New testsuite, ``testsuite.test_utils`` for testing testsuite - tools. -- :support:`-` New command, ``before_script``, which is a file to - be executed with a return code. It can be a bash, perl, python etc. - script. -- :support:`56` :ref:`python_api_quickstart ` - -- :release:`0.1.7 <2014-02-25>` -- :support:`55` where tmuxp would crash with letter numbers in version. - Write tests. - -- :release:`0.1.6 <2014-02-08>` -- :support:`-` :meth:`Window.split_window()` now allows ``-c - start_directory``. -- :support:`35` Builder will now use ``-c start_directory`` to - create new windows and panes. - - This removes a hack where ``default-path`` would be set for new pane and - window creations. This would bleed into tmux user sessions after - creations. - -- :release:`0.1.5-1 <2014-02-05>` -- :bug:`49` bug where ``package_manifest.py`` missing - from ``MANIFEST.in`` would cause error installing. - -- :release:`0.1.5 <2014-02-05>` -- :support:`-` section heading normalization. -- :support:`-` tao of tmux section now treated as a chatper. tao of tmux may be - split off into its own project. -- :support:`-` use conventions from `tony/cookiecutter-pypackage`_. - -- :release:`0.1.4 <2014-02-02>` -- :support:`-` Fix ``$ tmuxp freeze`` CLI output. -- :support:`-` Update ``_compat`` support module. -- :support:`-` Fix extra space in `PEP 263`_. - -- :release:`0.1.3 <2014-01-29>` -- :bug:`48` Fix Python 3 CLI issue. -- :bug:`48` ``$ tmuxp`` without option raises an error. -- :support:`-` - Add space before send-keys to not populate bash and zsh - history. - -- :release:`0.1.2 <2014-01-08>` -- :support:`-` now using werkzeug / flask style testsuites. -- :support:`43` Merge ``tmuxp -d`` for loading in detached mode. - Thanks `roxit`_. - -- :release:`0.1.1 <2013-12-25>` -- :bug:`32` Fix bug where special characters caused unicode caused - unexpected outcomes loading and freezing sessions. - -- :release:`0.1.0 <2013-12-18>` -- :support:`-` fix duplicate print out of filename with using ``tmuxp load .``. -- version to 0.1. No ``--pre`` needed. Future versions will not use rc. - -- :release:`0.1-rc8 <2013-12-17>` -- :support:`-` ``unicode_literals`` -- :support:`-` Move py2/py3 compliancy code to ``_compat``. - -- :release:`0.1-rc7 <2013-12-07>` -- :bug:`33` Partial rewrite of :meth:`config.expand`. -- :support:`-` tmuxp will exit silently with ``Ctrl-c``. - -- :release:`0.1-rc6 <2013-12-06>` -- :support:`31` [examples] from stratoukos add ``window_index`` option, - and example. - -- :release:`0.1-rc5 <2013-12-04>` -- :support:`28` shell_command_before in session scope of config causing - duplication. New test. -- :bug:`26` :bug:`29` for OS X tests. Thanks stratoukos. -- :bug:`27` ``$ tmuxp freeze`` raises unhelpful message if session doesn't - exist. - -- :release:`0.1-rc4 <2013-12-03>` -- :bug:`-` fix bug were ``focus: true`` would not launch sessions when using - ``$ tmuxp load`` in a tmux session. - -- :release:`0.1-rc3 <2013-12-03>` -- :bug:`25` ``focus: true`` not working in panes. Add - tests for focusing panes in config. -- :support:`-` :meth:`Pane.select_pane()`. -- :support:`-` add new example for ``focus: true``. - -- :release:`0.1-rc2 <2013-11-23>` -- :bug:`23` fix bug where workspace would not build with pane-base-index - set to 1. Update tests to fix if ``pane-base-index`` is not 0. -- :support:`-` - removed ``$ tmuxp load --list`` functionality. Update - :ref:`quickstart` accordingly. - -- :release:`0.1-rc1 <2013-11-23>` -- :support:`-` `pep8`_ and `pep257`_ in unit tests. -- Changelog will now be updated on a version basis, use `pep440`_ - versioning. - -- :release:`0.1-dev <2013-11-21>` -- :support:`-` :meth:`Session.show_options`, :meth:`Session.show_option` now - accept ``g`` to pass in ``-g``. - -- :release:`0.1-dev <2013-11-20>` -- :support:`-` :meth:`Window.show_window_options`, - :meth:`Window.show_window_option` now accept ``g`` to pass in ``-g``. -- :bug:`15` Behavioral changes in the WorkspaceBuilder to fix pane - ordering. -- :bug:`21` Error with unit testing python 2.6 python configuration tests. - Use :py:mod:`tempfile` instead. -- :support:`-` WorkspaceBuilder tests have been improved to use async better. - - -- :release:`0.1-dev <2013-11-17>` -- :support:`-` fix a bug where missing tmux didn't show correct warning. - -- :release:`0.1-dev <2013-11-15>` -- :support:`-` Travis now tests python 2.6 as requirement and not allowed to - fail. - -- :release:`0.1-dev <2013-11-13>` -- :support:`19` accept ``-y`` argument to answer yes to questions. -- :support:`-` :meth:`cli.SessionCompleter` no longer allows a duplicate session - after one is added. -- :support:`-` ongoing work on :ref:`about_tmux`. - -- :release:`0.1-dev <2013-11-09>` -- :support:`-` [translation] `documentation in Chinese`_ from `wrongwaycn`_. -- :support:`-` More work done on the :ref:`about_tmux` page. -- :support:`-` :meth:`Pane.split_window()` for splitting :class:`Window` at - ``target-pane`` location. - -- :release:`0.1-dev <2013-11-08>` -- :support:`-` [freeze] - ``$ tmuxp freeze`` will now freeze a window with a - ``start_directory`` when all panes in a window are inside the same - directory. -- :support:`-` [config] :support:`-` :meth:`config.inline` will now turn panes with no - other attributes and 1 command into a single item value. - - .. code-block:: yaml - - - panes: - - shell_command: top - - # will now inline to: - - - panes - - top - - This will improve ``$ tmuxp freeze`` - -- :release:`0.1-dev <2013-11-07>` -- :support:`-` Remove old logger (based on `tornado's log.py`_), replace - with new, simpler one. -- :support:`-` fix `teamocil`_ import. -- :feature:`-` support import teamocil ``root`` to - ``start_directory``. - -- :release:`0.1-dev <2013-11-06>` -- :support:`-` tagged v0.0.37. Many fixes. Python 2.6 support. Will - switch to per-version changelog after 0.1 release. -- :feature:`-` support for blank panes (null, ``pane``, ``blank``) and panes - with empty strings. -- :feature:`-` tmuxp freeze supports exporting to blank panes. -- :feature:`-` tmuxp freeze will now return a blank pane for panes that would - previously return a duplicate shell command, or generic python, node - interpreter. - -- :release:`0.1-dev <2013-11-05>` -- :support:`-` Support for ``[-L socket-name]`` and ``[-S socket-path]`` in - autocompletion and when loading. Note, switching client into another - socket may cause an error. -- Documentation tweaking to :ref:`API`, :ref:`about_tmux`. -- New :ref:`roadmap`. -- `pep257`_, `pep8`_. - -- :release:`0.1-dev <2013-11-04>` -- :support:`-` `pep257`_, `pep8`_. -- tagged version ``v0.0.36``. - -- :release:`0.1-dev <2013-11-02>` -- :support:`-` Many documentation, `pep257`_, `pep8`_ fixes -- :support:`-` move old :class:`Server` methods ``__list_panes()``, - ``__list_windows`` and ``__list_sessions`` into the single underscore. -- :support:`12` fix for ``$ tmuxp freeze`` by @finder. -- :support:`-` Support for spaces in ``$ tmuxp attach-session`` and - ``$ tmuxp kill-session``, and ``$ tmuxp freeze``. -- [config] :support:`-` support for relative paths of ``start_directory``. Add an - update config in *Start Directory* on :ref:`examples`. - -- :release:`0.1-dev <2013-11-01>` -- :support:`-` New servers for :class:`Server` arguments ``socket_name``, - ``socket_path``, ``config_file``. -- :support:`-` :class:`Server` support for ``-2`` with ``colors=256`` and - ``colors=8``. -- :support:`-` ``$ tmuxp -2`` for forcing 256 colors and ``tmuxp -8`` for forcing 88. -- [config] :support:`-` Concatenation with ``start_directory`` via - :meth:`config.trickle()` if window ``start_directory`` is alphanumeric / - relative (doesn't start with ``/``). See :ref:`Examples` in *start directory*. -- :support:`-` Fix bug with import teamocil and tmuxinator -- :support:`-` Improve quality of tmuxinator imports. Especially ``session_name`` - and ``start_directory``. -- :support:`-` Allow saving with ``~`` in file destination. - -- :release:`0.1-dev <2013-10-31>` -- :support:`-` :meth:`util.is_version()` -- :support:`-` correctly :meth:`config.trickle()` the ``start_directory``. -- :support:`-` get ``start_directory`` working for configs -- :support:`-` fix :meth:``Window.kill_window()`` target to - ``session_id:window_index`` for compatibility and pass tests. -- :support:`-` [examples]: Example for ``start_directory``. -- :support:`-` fix bug where first and second window would load in mixed order -- :support:`-` :class:`Window.move_window()` for moving window. -- :support:`-` major doc overhaul. front page, renamed orm_al.rst to internals.rst. - -- :release:`0.1-dev <2013-10-30>` -- :support:`-` fix bug where if inside tmux, loading a workspace via switch_client - wouldn't work. -- :support:`-` fix bug where ``tmuxp load .`` would return an error instead of a - notice. -- :support:`-` ``tmuxp freeze `` experimental -- :feature:`-` tmuxp now has experimental support for freezing live - sessions. -- :support:`-` :meth:`Window.kill_window()` -- :support:`-` support for ``start_directory`` (work in progress) - -- :release:`0.1-dev <2013-10-29>` -- :support:`-` :meth:`Window.select_pane` now accepts ``-l``, ``-U``, ``-D``, - ``-L``, ``-R``. -- :support:`-` support for ``automatic-rename`` option. -- :support:`-` 3 new :ref:`examples`, 'main-pane-height', 'automatic-rename', and - 'shorthands'. -- :support:`-` enhancements to prompts -- :support:`-` ``tmuxp import`` for teamocil and tmuxinator now has a wizard and offers - to save in JSON or YAML format. -- :bug:`-` [b6c2e84] Fix bug where tmuxp load w/ session already loaded would - switch/attach even if no was entered -- :support:`-` when workspace loader crashes, give option to kill session, attach it or - detach it. -- :support:`-` tmux 1.8 ``set-option`` / ``set-window-options`` command - ``target-window`` fix. -- :support:`-` :class:`WorkspaceBuilder` now has ``.session`` attribute accessible - publicly. -- :support:`-` tmux will now use :meth:`Session.switch_client` and - :meth:`Session.attach_session` to open new sessions instead of ``os.exec``. -- [config] tmuxp now allows a new shorter form for panes. Panes can just be a - string. See the shorthand form in the :ref:`examples` section. -- :support:`-` [config] support loading ``.yml``. - -- :release:`0.1-dev <2013-10-28>` -- :bug:`-` fix ``tmuxp load .`` fixed -- :bug:`-` fix ``tmuxp convert `` fixed. -- :support:`-` `pep257`_ fixes. -- :support:`-` :class:`Pane` now has :meth:`Pane.set_width` and - :meth:`Pane.set_height`. -- :support:`-` ``./run_tests.py --tests`` now automatically prepends - ``tmuxp.testsuite`` to names. -- :support:`-` :meth:`Window.tmux` and :meth:`Pane.tmux` will automatically add - their ``{window/pane}_id`` if one isn't specific. - -- :release:`0.1-dev <2013-10-27>` -- :support:`-` `argcomplete`_ overhaul for CLI bash completion. -- :support:`-` ``tmuxp load``, ``tmuxp convert`` and ``tmuxp import`` now support - relative and full filenames in addition to searching the config - directory. - -- :release:`0.1-dev <2013-10-26>` -- :support:`-` initial version of `tmuxinator`_ and `teamocil`_ - config importer. it does not support all options and it not guaranteed - to fully convert window/pane size and state. -- :support:`-` :meth:`config.in_dir` supports a list of ``extensions`` for - filetypes to search, i.e. ``['.yaml', '.json']``. -- :support:`-` :meth:`config.is_config_file` now supports ``extensions`` - argument as a string also. -- :support:`-` fix ``$ tmuxp load -l`` to work correctly alongside - ``$ tmuxp load filename``. - -- :release:`0.1-dev <2013-10-25>` -- :support:`-` fix bug where ``-v`` and ``--version`` wouldn't print version. -- :support:`-` property handle case where no tmux server exists when - ``attach-session`` or ``kill-session`` is used. -- :support:`-` test fixtures and inital work for importing - `tmuxinator`_ and `teamocil`_ configs - -- :release:`0.1-dev <2013-10-24>` -- :support:`-` clean out old code for ``automatic-rename`` option. it will - be reimplemented fresh. -- :support:`-` check for ``oh-my-zsh`` when using ``$SHELL`` ``zsh``. Prompt if - ``DISABLE_AUTO_TITLE`` is unset or set to ``true``. -- :support:`-` tmuxp can now ``$ tmuxp convert `` from JSON <=> YAML, back - and forth. -- :support:`-` New examples in JSON. Update the :ref:`examples` page in the - docs. -- [dev] ``.tmuxp.json`` now exists as a config for tmuxp development and - as an example. -- :support:`-` Fix bug where ``tmuxp kill-session`` would give bad output -- :support:`-` Fix bug in tab completion for listing sessions with no tmux server - is active. - -- :release:`0.1-dev <2013-10-23>` -- :support:`-` zsh/bash/tcsh completion improvements for tab-completion options -- :support:`-` tmuxp ``kill-session`` with tab-completion. -- :support:`-` tmuxp ``attach-session`` with tab-completion. Attach session will - ``switch-client`` for you if you are inside of of a tmux client. -- :support:`-` tmuxp ``load`` for loading configs. -- :support:`-` unit test fixes. - -- :release:`0.1-dev <2013-10-21>` -- :support:`-` Make 1.8 the official minimym version, give warning notice to - upgrade tmux if out of date -- Fix regression causing unexpected build behavior due to unremoved code - supporting old tmux versions. -- :support:`-` Added 2 new examples to the :ref:`examples` page. -- :support:`-` Examples now have graphics -- :support:`-` ``$ tmuxp -v`` will print the version info. - -- :release:`0.1-dev <2013-10-19>` -- :support:`-` tmuxp will now give warning and sys.exit() with a message if - ``tmux`` not found in system PATH -- :support:`-` major internal overhaul of :class:`Server`, :class:`Session` - , :class:`Window`, and :class:`Pane` continues. - - - :class:`Server` has @property :meth:`Server.sessions`, which is forward - to :meth:`Server.list_sessions()` (kept to keep tmux commands in - serendipty with api), :meth:`Server._list_sessions()` returns dict - object from :meth:`Server.__list_sessions()` tmux command. - :meth:`Server.__list_sessions()` exists to keep the command layered so - it can be tested against in a matrix with travis and compatibility - methods can be made. - - - :class:`Session` now has @proprety :meth:`Session.windows` returning a - list of :class:`Window` objects via :meth:`Session.list_windows()`. - @property :meth:`Session._windows` to :meth:`Session._list_windows()` - to return a list of dicts without making objects. - - - :class:`Window` now has @proprety :meth:`Window.panes` returning a - list of :class:`Pane` objects via :meth:`Window.list_panes()`. - @property :meth:`Window._panes` to :meth:`Window._list_panes()` - to return a list of dicts without making objects. - -- :release:`0.1-dev <2013-10-18>` -- :support:`-` major internal overhaul of :class:`Server`, :class:`Session`, - :class:`Window`, and :class:`Pane`. - - - ``Session``, ``Window`` and ``Pane`` now refer to a data object - in :class:`Server` internally and always pull the freshest data. - - - A lot of code and complexity regarding setting new data for objects - has been reduced since objects use their unique key identifier to - filter their objects through the windows and panes in ``Server`` - object. - - - ``Server`` object is what does the updating now. -- [project] some research into supporting legacy tmux versions. tmux 1.6 - and 1.7 support seem likely eventually if there is enough demand. -- :support:`-` python 3 support - -- :release:`0.1-dev <2013-10-17>` -- :support:`-` updated README docs with new project details, screenshots -- :support:`-` - new example ``.tmuxp.yaml`` file updated to include - development workflow. Removed nodemon as the tool for checking files for - now. -- :support:`-` Support for switching sessions from within tmux. In both cases - after the the session is built and if session already exists. - -- :release:`0.1-dev <2013-10-16>` -- :support:`-` use :meth:`util.which()` from salt.util to find tmux binary. -- :support:`-` add MANIFEST.in, fix issue where package would not - install because missing file -- :support:`-` bash / zsh completion. -- :support:`-` New page on :ref:`internals`. -- :support:`-` Updates to :ref:`about_tmux` page. -- :support:`-` add vim modeline for rst to bottom of this page -- :support:`-` Server is now a subclass of ``util.TmuxObject``. -- :support:`-` subclasses of :class:`util.TmuxRelationalObject`, - :class:`Server`, :class:`Session`, :class:`Window`, :class:`Pane` now - have :meth:`util.TmuxRelationalObject.getById` (similar to `.get()`_ in - `backbone.js`_ collection), :meth:`util.TmuxRelationalObject.where` and - :meth:`util.TmuxRelationalObject.findWhere` (`.where()`_ and - `.findWhere()`_ in `underscore.js`_), to easily find child objects. -- :support:`-` tmux object mapping has been split into - :class:`util.TmuxMappingObject`. The mapping and the relational has been - decoupled to allow :class:`Server` to have children while not being a - dict-like object. -- :support:`-` :class:`Server`, :class:`Session`, :class:`Window`, - :class:`Pane` now explicitly mixin subclasses. - -- :release:`0.1-dev <2013-10-15>` -- :support:`-` new theme -- :support:`-` initial examples, misc. updates, front page update. -- :support:`-` support for ``$ tmux .`` to load ``.tmuxp.{yaml/json/py}`` in current - working directory. -- :support:`-` support for ``socket-name`` (``-L``) and ``socket-path`` - (``socket-path``) -- [config] :support:`-` Support for 1-command pane items. - - .. code-block:: yaml - - session_name: my session - windows: - - window_name: hi - panes: - - bash - - htop -- :support:`-` If session name is already exists, prompt to attach. - -- :release:`0.1-dev <2013-10-14>` -- :support:`-` can now -l to list configs in current directory and $HOME/.tmuxp -- :support:`-` tmuxp can now launch configs and build sessions -- :support:`-` new exceptions -- :support:`-` :meth:`config.check_consistency()` to verify and diagnose - issues with config files. -- :support:`-` :meth:`cli.startup()` -- :support:`-` :meth:`config.is_config_file()` -- :support:`-` :meth:`config.in_dir()` -- :support:`-` :meth:`config.in_cwd()` - -- :release:`0.1-dev <2013-10-13>` -- :support:`-` :meth:`config.inline()` to produce far far better looking - config exports and tests. -- :meth:`Pane.resize_pane()` and tests -- documentation fixes and updates -- :support:`-` :meth:`Session.refresh()`, :meth:`Window.refresh()`, - :meth:`Pane.refresh()`. -- :support:`-` :meth:`Server.find()`, :meth:`Session.find()`, - :meth:`Window.find()`. - -- :release:`0.1-dev <2013-10-12>` -- :support:`-` Test documentation updates -- :support:`-` Builder is now :class:`WorkspaceBuilder` + tests. - - WorkspaceBuilder can build panes - - WorkspaceBuilder can build windows and set options -- :support:`-` :meth:`Window.show_window_options()`, - :meth:`Window.show_window_option()`, :meth:`Window.set_window_option()` -- :support:`-` :meth:`Session.show_options()`, - :meth:`Session.show_option()`, :meth:`Session.set_option()` - -- :release:`0.1-dev <2013-10-11>` -- :support:`-` More preparation for builder / session maker utility. -- :support:`-` Major test runner and test suite overhaul. -- :support:`-` Documentation for development environment and test runner updated. -- :support:`-` Travis now tests against tmux 1.8 and latest source. Door open for - future testing against python 3 and earlier tmux versions in the future. -- :support:`-` Quiet logger down in some areas -- :support:`-` __future__ imports for future python 3 compatibility -- :support:`-` setup.py import __version__ via regex from tmuxp package -- :support:`-` move beginnings of cli to ``tmuxp.cli`` - -- :release:`0.1-dev <2013-10-09>` -- :support:`-` New logging module -- :support:`-` Removed dependency logutils -- :support:`-` Removed dependency sh - -- :release:`0.1-dev <2013-10-08>` -- switch to semver - -.. _tmuxinator: https://github.com/aziz/tmuxinator -.. _teamocil: https://github.com/remiprev/teamocil -.. _argcomplete: https://github.com/kislyuk/argcomplete -.. _pep257: http://www.python.org/dev/peps/pep-0257/ -.. _pep8: http://www.python.org/dev/peps/pep-0008/ -.. _pep440: http://www.python.org/dev/peps/pep-0440/ -.. _tony/cookiecutter-pypackage: https://github.com/tony/cookiecutter-pypackage - -.. _@tasdomas: https://github.com/tasdomas -.. _@sehe: https://github.com/sehe -.. _@madprog: https://github.com/madprog - -.. _autoenv: https://github.com/kennethreitz/autoenv -.. _entr(1): http://entrproject.org/ -.. _Anaconda Python: http://docs.continuum.io/anaconda/index - -.. _@estin: https://github.com/estin - -.. _sysutils/pytmuxp: http://www.freshports.org/sysutils/py-tmuxp/ -.. _#201564: https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=201564 - -.. _colorama: https://pypi.python.org/pypi/colorama -.. _@marbu: https://github.com/marbu -.. _@yegortimoshenko: https://github.com/yegortimoshenko -.. _@thomasballinger: https://github.com/thomasballinger - -.. _sniffer: https://github.com/jeffh/sniffer -.. _watchingtestrunner: https://pypi.python.org/pypi/watching_testrunner/1.0 -.. _@raine: https://github.com/raine -.. _@thomasballinger: https://github.com/thomasballinger -.. _@techtonik: https://github.com/techtonik - -.. _@ThiefMaster: https://github.com/ThiefMaster - -.. _PEP 263: http://www.python.org/dev/peps/pep-0263/ - -.. _roxit: https://github.com/roxit - -.. _documentation in Chinese: http://tmuxp-zh.readthedocs.io -.. _wrongwaycn: https://github.com/wrongwaycn - -.. _tornado's log.py: https://github.com/facebook/tornado/blob/master/tornado/log.py - -.. _underscore.js: http://underscorejs.org/ -.. _backbone.js: http://backbonejs.org/ -.. _.get(): http://backbonejs.org/#Collection-get -.. _.where(): http://underscorejs.org/#where -.. _.findWhere(): http://underscorejs.org/#findWhere - -.. _libtmux: https://github.com/tony/libtmux - -.. # vim: set filetype=rst: +# Changelog + +To install the unreleased tmuxp version, see {ref}`developmental-releases`. + +[pip](https://pip.pypa.io/en/stable/): + +```console +$ pip install --user --upgrade --pre tmuxp +``` + +[uv](https://docs.astral.sh/uv/getting-started/features/#python-versions): + +```console +$ uv add tmuxp --prerelease allow +``` + +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from 'tmuxp' --prerelease allow tmuxp +``` + +[pipx](https://pypa.github.io/pipx/docs/): + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Run the developmental install with: + +```console +$ tmuxp@next load yoursession +``` + +## tmuxp 1.75.0 (Yet to be released) + + + + +_Notes on the upcoming release will go here._ + + +### Documentation + +#### Diagrams stay readable on narrow screens (#1076) + +Every diagram now declares alt text, a stable link target, and a responsive +policy that scales it to the content column, and the architecture chart flows +top-to-bottom instead of running wide. Phone-width layouts and screen readers +get the same information as the desktop site. + +#### Inline commands and paths are highlighted (#1076) + +Inline mentions of `tmuxp` commands and file paths now carry syntax +highlighting via the shared `sphinx-gp-highlighting` package, and the plugin +module layout renders as a highlighted directory tree. + +## tmuxp 1.74.0 (2026-07-04) + +tmuxp 1.74.0 pairs a libtmux upgrade with a documentation overhaul. It bumps libtmux to 0.61.0 — hardening tmux 3.7 patch-line support — and teaches `tmuxp debug-info` to report the exact tmux patch release (`3.7a`/`3.7b`) instead of the numeric-normalized version. The docs also gain theme-aware inline diagrams and a concept-first rewrite that leads with what each feature is before its configuration. + +### Dependencies + +#### Minimum `libtmux~=0.61.0` (was `~=0.60.0`) (#1074) + +Picks up libtmux 0.61.0, which hardens tmux 3.7 patch-line support: +{meth}`~libtmux.Pane.break_pane` keeps tmux's own default window name on +tmux 3.7a/3.7b instead of forcing `libtmux`, and the new +{func}`~libtmux.common.get_version_str` exposes the raw tmux version +string. tmux 3.2a-3.6 are unaffected. + +### What's new + +#### `tmuxp debug-info` reports the exact tmux patch release (#1074) + +`tmuxp debug-info` now shows tmux's full version, keeping the +point-release letter (for example `3.7a`) that was previously normalized +away to `3.7`. Because tmux patch releases can differ in behavior, the +precise release is what a bug report needs. This reads libtmux 0.61.0's +{func}`~libtmux.common.get_version_str`. + +### Documentation + +#### Themed diagrams across the docs (#1071) + +The docs now render their diagrams as theme-aware inline graphics that follow the +site's light/dark palette and code styling, replacing the old static ASCII art. +{ref}`workspace-builders` gains a load → build → attach flow diagram and opens +with a concept-first overview, and {ref}`examples` shows each pane example as a +faithful tmux window layout. + +#### Concept-first documentation pass (#1072) + +The docs now open with what each feature *is* before its configuration, in a +consistent second-person voice, with advanced and Python-only material marked +opt-in. New diagrams trace the plugin hook lifecycle, the CLI request flow, the +workspace hierarchy, and what `tmuxp load` does inside an existing session, and +more internal API cross-references resolve to their pages. The {ref}`quickstart` +and {ref}`about-tmux` primer also pick up corrected version requirements, +tooling, and commands. + +### Development + +#### Docs adopt the upstream `sphinx-gp-mermaid` renderer (#1073) + +The build-time mermaid diagram renderer, previously bundled with the docs, now +comes from the reusable `sphinx-gp-mermaid` package (a dev/docs dependency). +Rendered output is unchanged. + +## tmuxp 1.73.0 (2026-06-28) + +tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide. + +### What's new + +#### Pluggable workspace builders (#1066) + +tmuxp can now build a workspace with a builder other than its built-in one. Point +a workspace file's `workspace_builder` key at an installed builder — by registered +name or by Python import path — and tmuxp loads the session through it. Builders +that ship outside tmuxp's environment can be imported from directories listed in +`workspace_builder_paths`, which tmuxp trusts only for that load. The built-in +builder stays the default, so existing workspaces are unaffected. See +{ref}`custom-workspace-builders`. + +#### Configurable pane readiness (#1066) + +Before sending a pane's layout and commands, tmuxp can wait for the shell to draw +its prompt — a guard against a zsh prompt-redraw artifact. That wait is now a +policy you set per workspace: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (also true/false) +``` + +The new default, `auto`, waits only when the session's shell is zsh, so zsh users +see no change while bash, sh, and other shells skip a wait they never needed. Set +`always` to keep the previous wait-everywhere behavior, or `never` to skip it +entirely. See {ref}`custom-workspace-builders`. + +#### Workspace builder API (#1066) + +`tmuxp.workspace.builder` is now a package. The default builder is +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, with +`WorkspaceBuilder` kept as a backwards-compatible alias. Third-party builders +implement {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +(shaped to allow async builders later) and are resolved by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` from an entry +point or import path, with trusted import directories handled by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths`. Builder behavior +is configured through {class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` +and {class}`~tmuxp.workspace.options.PaneReadiness`, and resolution failures raise +{exc}`~tmuxp.exc.WorkspaceBuilderError` and its subclasses. See +{ref}`custom-workspace-builders` for the guide. + +## tmuxp 1.72.0 (2026-06-28) + +tmuxp 1.72.0 bumps libtmux to 0.60.0, completing tmux 3.7 feature parity at the library layer tmuxp builds on. The new libtmux capabilities — floating panes, typed tmux 3.7 options, new pane format variables, and new command flags — are reachable through tmuxp's Python API and `tmuxp shell`, while the YAML workspace format and tmux 3.2a-3.6 support stay unchanged. + +### Dependencies + +#### Minimum `libtmux~=0.60.0` (was `~=0.59.0`) (#1065) + +### What's new + +#### libtmux 0.60.0 completes tmux 3.7 feature parity (#1065) + +The libtmux floor bump brings tmux 3.7 feature parity to the library layer tmuxp builds on: floating panes ({meth}`~libtmux.Window.new_pane`), typed tmux 3.7 server, session, window, and pane options, new pane format variables, and new tmux 3.7 command flags. Every 3.7-only surface is version-gated, so tmux 3.2a-3.6 are unaffected. These APIs are reachable through tmuxp's Python API and `tmuxp shell`; the YAML workspace format is unchanged. + +## tmuxp 1.71.0 (2026-06-27) + +tmuxp 1.71.0 bumps libtmux to 0.59.0, adding support for tmux 3.7. + +### Dependencies + +#### Minimum `libtmux~=0.59.0` (was `~=0.58.1`) (#1057) + +### What's new + +#### tmux 3.7 support (#1057) + +tmuxp now supports tmux 3.7, which joins 3.2a through 3.6 in the tested version range. There are no behavior changes for users on earlier tmux releases. + +## tmuxp 1.70.1 (2026-06-16) + +tmuxp 1.70.1 bumps libtmux to 0.58.1, fixing a pytest 9.1 plugin import failure that broke test collection for projects using tmuxp's or libtmux's fixtures. + +### Dependencies + +#### Minimum `libtmux~=0.58.1` (was `~=0.58.0`) (#1052) + +### Fixes + +#### pytest 9.1 plugin import compatibility (#1052) + +libtmux's pytest plugin aborted at import under pytest 9.1, breaking test collection for projects that use tmuxp's or libtmux's fixtures. Fixed by the libtmux 0.58.1 floor bump. + +## tmuxp 1.70.0 (2026-05-23) + +tmuxp 1.70.0 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. + +### Dependencies + +#### Minimum `libtmux~=0.58.0` (was `~=0.57.1`) (#1045) + +### Fixes + +#### Non-UTF-8 locale decoding (#1045) + +Session and window listing could silently return empty results when the system locale was not UTF-8. Fixed by the libtmux 0.58.0 floor bump. + +## tmuxp 1.69.0 (2026-05-23) + +tmuxp 1.69.0 is a libtmux that expands functionality of `tmuxp shell` under the hood. + +### Dependencies + +#### Minimum `libtmux~=0.57.1` (was `~=0.56.0`) (#1043) + +The libtmux bump pulls in the 0.57.x command-coverage and client-awareness releases. Scripts launched through {ref}`tmuxp-shell` can now work with attached-client objects and use tmux-native filtering to query sessions, windows, and panes without manual post-filtering. + +## tmuxp 1.68.0 (2026-05-10) + +tmuxp 1.68.0 refreshes the documentation stack and developer workflow for the next release train. The docs now sit on the shared [gp-sphinx](https://gp-sphinx.git-pull.com/) platform, the API pages pick up the newer gp-furo visual language, and the test suite avoids paying for each contributor's interactive shell startup. The release also raises the libtmux floor so {ref}`tmuxp-shell` users can script against the expanded tmux command wrapper surface. + +### Dependencies + +#### Minimum `libtmux~=0.56.0` (was `~=0.55.0`) (#1038) + +The libtmux bump pulls in the 0.55.1 test-fixture socket cleanup and the 0.56.0 command-coverage release. tmuxp's runtime imports stay on stable APIs, while scripts launched through {ref}`tmuxp-shell` can now reach more upstream wrappers for interactive commands, buffer I/O, key bindings, and window/pane manipulation without falling back to raw `cmd()` calls. + +### Documentation + +#### Shared gp-sphinx documentation platform (#1033, #1035, #1036, #1037) + +The docs move from repo-local Sphinx extension copies to the shared gp-sphinx package family. That makes the tmuxp site inherit the common Furo-based theme, IBM Plex typography, packaged argparse documentation helpers, API badge styling, and MyST-aware object references used across the surrounding Python projects. + +The later gp-sphinx bumps bring in the `gp-furo-theme` Tailwind v4 respin and `sphinx-vite-builder` asset pipeline. In practice, this keeps tmuxp's CLI and API docs visually aligned with the newer [libtmux-mcp](https://libtmux-mcp.git-pull.com/) docs without keeping private copies of the same theme and extension code. + +#### Documentation command blocks are easier to copy (#1024) + +Shell examples now use one command per `console` code block with a `$` prompt. Long commands are split with continuations so rendered docs stay readable while still copying cleanly. + +### Development + +#### Test panes spawn `/bin/sh` instead of the developer's `$SHELL` (#1041) + +The pytest suite now pins `$SHELL=/bin/sh` while tests run, so tmux panes created by fixtures skip the developer's interactive shell startup files. That removes a large local performance cost for zsh or heavily customized shell setups and does not change tmuxp runtime behavior. + +## tmuxp 1.67.0 (2026-03-08) + +tmuxp 1.67.0 makes {ref}`tmuxp-load` visibly track the workspace build it is performing. Users get progress feedback while windows, panes, and `before_script` hooks are being prepared, and automation can still disable the display entirely. + +### What's new + +#### Animated progress for `tmuxp load` (#1020) + +The {ref}`tmuxp-load` command now shows an animated progress display while it builds a session. Built-in formats cover terse, window-focused, pane-focused, and verbose views, while `--progress-format` and `TMUXP_PROGRESS_FORMAT` allow a custom display. + +`--progress-lines` and `TMUXP_PROGRESS_LINES` control how much `before_script` output appears in the panel, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. + +## tmuxp 1.66.0 (2026-03-08) + +tmuxp 1.66.0 cleans up diagnostics and user-facing output. Normal CLI use is quieter, build failures stop dumping raw tracebacks by default, and structured logging becomes the common diagnostic path. + +### Fixes + +#### Quieter CLI failures and machine-readable output fixes (#1017) + +The default CLI log level moved from INFO to WARNING so routine commands no longer emit internal chatter. Workspace build failures now show a concise user-facing error unless `--log-level debug` is requested, `get_pane()` preserves exception context without printing directly, and JSON output for `ls` and `debug-info` now flows through the shared output formatter. + +### Development + +#### Structured logging across tmuxp (#1017) + +Modules now use named loggers with structured `extra` fields such as `tmux_session`, `tmux_window`, `tmux_pane`, and `tmux_config_path`. Library imports install a `NullHandler`, and stable objects can carry persistent context through a logger adapter. + +The same cleanup removes the `colorama` dependency in favor of stdlib ANSI helpers and routes direct user-visible printing through tmuxp's output layer. + +## tmuxp 1.65.0 (2026-03-08) + +tmuxp 1.65.0 is a libtmux compatibility release. It raises the floor to pick up upstream lifecycle logging, error propagation fixes, pane title support, configurable tmux binary support, and safer pre-execution command logging. + +### Dependencies + +#### Minimum `libtmux~=0.55.0` (was `~=0.53.0`) (#1019) + +This pulls in libtmux 0.53.1, 0.54.0, and 0.55.0. The most visible downstream effects are the upstream `new_session()` race fix, structured lifecycle logging, `Pane.set_title()`, `Server(tmux_bin=)`, and DEBUG logging before tmux subprocess execution. + +## tmuxp 1.64.2 (2026-03-08) + +tmuxp 1.64.2 is a packaging-only correction for the previous patch release. + +### Fixes + +#### Published version metadata corrected + +The package version in `__about__.__version__` is updated to match the 1.64.1 release. + +## tmuxp 1.64.1 (2026-03-08) + +tmuxp 1.64.1 fixes a long-standing zsh display artifact during workspace load and adds linkable, richer CLI option documentation. + +### Fixes + +#### Pane readiness before layout and commands (#1018) + +Workspace loading now waits for each pane's shell to become ready before applying layout or sending commands. This fixes the inverse `%` marker that zsh could leave in panes after loading a workspace ([#365](https://github.com/tmux-python/tmuxp/issues/365)). + +### Documentation + +#### Linkable and richer CLI arguments (#1010, #1011) + +CLI docs now expose permanent anchors for command options and positional arguments. Argument metadata is rendered as readable key/value detail with required badges, default-value styling, and header links for sharing a specific option. + +## tmuxp 1.64.0 (2026-01-24) + +tmuxp 1.64.0 replaces the first-generation argparse docs extension with a maintained package and fixes the warnings that surfaced during that migration. + +### Documentation + +#### CLI docs powered by `sphinx_argparse_neo` (#1009) + +The CLI reference moves to a packaged argparse documentation engine with syntax-highlighted usage blocks, transformed examples sections, ANSI stripping, safer RST escaping, and a page structure consistent with sibling projects. + +### Fixes + +#### Docutils and typing cleanup (#1009) + +The docs extension no longer assigns directly to `node.children`, preserving docutils parent/child tracking, and a missing type annotation was added to satisfy mypy. + +## tmuxp 1.63.1 (2026-01-11) + +tmuxp 1.63.1 is a small CLI help fix after the colorized command release. + +### Fixes + +#### Example sections colorize correctly (#1008) + +Example headings in `tmuxp --help` now use the command-specific heading format needed by the formatter, so example sections receive the same styling as other help output. + +## tmuxp 1.63.0 (2026-01-11) + +tmuxp 1.63.0 is the CLI usability release. It adds semantic color, a searchable workspace index, richer `ls` output, and JSON output for debugging and automation. + +### What's new + +#### Semantic color across CLI output (#1006) + +The root CLI gains `--color=auto|always|never` and respects `NO_COLOR` and `FORCE_COLOR`. Commands use semantic color roles for success, warnings, errors, paths, highlights, muted labels, interactive prompts, and help examples. + +#### Workspace search command (#1006) + +The new {ref}`search-config` command searches local and global workspace files with field prefixes such as `name:`, `session:`, `path:`, `window:`, and `pane:`. It supports smart-case, fixed-string, word, OR, and inverted matching, plus human, JSON, and NDJSON output. + +#### Richer workspace listing (#1006) + +The {ref}`ls-config` command can display grouped trees, complete parsed config content, local-versus-global source information, and machine-readable JSON/NDJSON output. Global workspace directories also show legacy and XDG locations with status. + +#### JSON debug information (#1006) + +{ref}`tmuxp-debug-info` can now emit structured JSON for automation and issue-reporting workflows. + +### Documentation + +#### Better CLI documentation primitives (#1007) + +The docs gained a pretty-argparse extension for stripping ANSI escape codes, turning help examples into documentation sections, highlighting usage blocks, and ordering generated sections more naturally. + +### Development + +#### Development tasks move to Just (#1005) + +The project development entrypoint moved from `Makefile` targets to `just` recipes, and the docs were updated around the new workflow. + +#### Docs deployment uses OIDC (#1000) + +The documentation deployment path moved to AWS OIDC credentials and the AWS CLI, removing long-lived credentials from the normal deploy flow. + +## tmuxp 1.62.0 (2025-12-14) + +tmuxp 1.62.0 pairs a libtmux floor bump with a load-time error fix. + +### Dependencies + +#### Minimum `libtmux~=0.53.0` (was `~=0.52.1`) (#1001, #1003) + +The libtmux bump keeps tmuxp aligned with the upstream session-management fixes required by the current test and runtime surface. + +### Fixes + +#### Session-killed tracebacks are surfaced correctly (#1002, #1003) + +When a session is killed during the attach path after `tmuxp load`, the failure now reports through the expected error path instead of hiding the traceback. + +## tmuxp 1.61.0 (2025-12-07) + +tmuxp 1.61.0 keeps the dependency stack on the newly published trusted-release line. + +### Dependencies + +#### Minimum `libtmux~=0.52.1` (was `~=0.51.0`) (#1001) + +The libtmux update picks up improved pane capture support and the upstream package's Trusted Publisher release. + +#### `gp-libs` trusted-publishing refresh (#1001) + +The gp-libs dependency moves to a package published through PyPI Trusted Publisher. + +## tmuxp 1.60.1 (2025-12-07) + +tmuxp 1.60.1 is a release-infrastructure update. + +### Development + +#### PyPI Trusted Publisher (#1000) + +tmuxp publishing moved to PyPI Trusted Publisher, reducing the need for long-lived upload credentials. + +## tmuxp 1.60.0 (2025-12-06) + +tmuxp 1.60.0 raises the libtmux floor to the release where old deprecation warnings became hard API errors. + +### Dependencies + +#### Minimum `libtmux~=0.51.0` (was `~=0.50.1`) (#999) + +Users and plugins relying on deprecated libtmux names should migrate before this line, because libtmux 0.51.0 turns some formerly warned APIs into errors. + +## tmuxp 1.59.1 (2025-12-06) + +tmuxp 1.59.1 is a docs-health and dependency patch. + +### Dependencies + +#### Minimum `libtmux~=0.50.1` (was `~=0.50.0`) (#998) + +The libtmux patch keeps tmuxp on the latest 0.50.x maintenance line. + +### Documentation + +#### Sphinx warnings fixed (#997) + +The docs build received typehint configuration, docstring typo fixes, missing `Returns` and `Raises` sections, RST parameter formatting fixes, and an orphan footnote cleanup. + +## tmuxp 1.59.0 (2025-11-30) + +tmuxp 1.59.0 adapts to libtmux's unified options API and its Python-native option values. + +### Dependencies + +#### Minimum `libtmux~=0.50.0` (was `~=0.49.0`) (#996) + +Internal tmuxp calls moved from the old libtmux option helpers to the newer unified option methods. Historical names such as `Session.attach_session()`, `Window.show_window_option()`, and `Window.set_window_option()` are intentionally left as inline code here because they describe the old migration surface. + +## tmuxp 1.58.0 (2025-11-30) + +tmuxp 1.58.0 completes the move away from old tmux versions. + +### Breaking changes + +#### tmux 3.2+ is now required (#992, #993) + +tmux versions below 3.2a are disabled through the libtmux 0.49.0 update. + +### Dependencies + +#### Minimum `libtmux~=0.49.0` (was `~=0.48.0`) (#992) + +The libtmux update carries the tmux-version enforcement used by tmuxp. + +## tmuxp 1.57.0 (2025-11-28) + +tmuxp 1.57.0 starts the tmux 3.2+ migration and expands test coverage to tmux 3.6. + +### Breaking changes + +#### tmux versions below 3.2a are deprecated (#991) + +Older tmux versions now emit a `FutureWarning` on first use through libtmux. Set `LIBTMUX_SUPPRESS_VERSION_WARNING=1` only when you intentionally need to silence the warning during a transition. + +### Dependencies + +#### Minimum `libtmux~=0.48.0` (was `~=0.47.0`) (#990) + +The libtmux floor carries the warning path for older tmux versions. + +### Development + +#### tmux 3.6 in the test grid (#989) + +CI now covers tmux 3.6. + +## tmuxp 1.56.0 (2025-11-01) + +tmuxp 1.56.0 drops Python 3.9 and starts the Python 3.14 readiness line. + +### Breaking changes + +#### Python 3.10+ is now required (#987) + +tmuxp 1.55.0 was the last release supporting Python 3.9. Python 3.9 reached end of life on October 5, 2025. + +#### Minimum `libtmux~=0.47.0` (was `~=0.46.0`) + +The libtmux floor moves with the supported Python baseline. + +### Development + +#### Python 3.14 added to CI (#986) + +The test matrix now includes Python 3.14. + +## tmuxp 1.55.0 (2025-02-26) + +tmuxp 1.55.0 is a maintenance release for libtmux test-helper compatibility. + +### Dependencies + +#### Minimum `libtmux~=0.46.0` (was `~=0.45.0`) (#969) + +The dependency bump keeps tmuxp current with upstream libtmux test helper changes. + +## tmuxp 1.54.0 (2025-02-23) + +tmuxp 1.54.0 is a maintenance release around libtmux and runtime-dependency checks. + +### Dependencies + +#### Minimum `libtmux~=0.45.0` (was `~=0.44.2`) (#968) + +The libtmux update keeps the project aligned with upstream test helper changes. + +### Development + +#### CLI runtime dependency checks (#967) + +CI now verifies runtime dependencies for CLI modules, extending the earlier dependency-import checks. + +## tmuxp 1.53.0 (2025-02-19) + +tmuxp 1.53.0 fixes a runtime typing import issue and makes the test suite easier to read. + +### Fixes + +#### Runtime import fix (#965) + +Imports that were only available for typing no longer leak into runtime paths. + +### Development + +#### Dependency and test fixture cleanup (#962, #964, #965) + +The libtmux floor moves to 0.44.2, CI verifies runtime dependencies, and parametrized tests were converted to named fixture records so failures show clearer IDs. + +## tmuxp 1.52.2 (2025-02-02) + +tmuxp 1.52.2 continues the `run_before_script()` output-capture fix line. + +### Fixes + +#### `run_before_script()` captures output more reliably (#960) + +Additional output-capture edge cases were fixed for pre-load scripts. + +## tmuxp 1.52.1 (2025-02-02) + +tmuxp 1.52.1 fixes the first output-capture issue in `run_before_script()`. + +### Fixes + +#### `run_before_script()` output fix (#959) + +The pre-load script runner now captures output through the corrected libtmux path. + +## tmuxp 1.52.0 (2025-02-02) + +tmuxp 1.52.0 is a maintenance release for the libtmux 0.42.0 line. + +### Dependencies + +#### Minimum `libtmux~=0.42.0` (was `~=0.40.1`) (#958) + +tmuxp drops its reliance on the older `console_to_str()` helper while staying on the current libtmux command-output path. + +## tmuxp 1.51.0 (2025-02-02) + +tmuxp 1.51.0 modernizes annotations and lint rules. + +### Development + +#### Deferred annotations and modern typing checks (#957) + +Source files now use `from __future__ import annotations`, and Ruff checks for PEP 585 and PEP 604 style annotations are enabled. + +## tmuxp 1.50.1 (2024-12-24) + +tmuxp 1.50.1 is a libtmux maintenance update. + +### Dependencies + +#### Minimum `libtmux~=0.40.1` (was `~=0.40.0`) (#956) + +The libtmux bump picks up the server environment variable fix from [libtmux#553](https://github.com/tmux-python/libtmux/pull/553). + +## tmuxp 1.50.0 (2024-12-20) + +tmuxp 1.50.0 modernizes syntax for the Python 3.9 baseline. + +### Dependencies + +#### Minimum `libtmux~=0.40.0` (was `~=0.39.0`) (#954) + +The libtmux update adopts Python 3.9 syntax features. + +### Development + +#### Ruff modernization pass (#953) + +The codebase was run through Ruff's automated fixes and formatter for the Python 3.9 baseline. + +## tmuxp 1.49.0 (2024-11-26) + +tmuxp 1.49.0 drops Python 3.8 and moves the dependency stack to the Python 3.9 line. + +### Breaking changes + +#### Python 3.9+ is now required (#951) + +tmuxp 1.48.0 was the last release supporting Python 3.8. Python 3.8 reached end of life on October 7, 2024. + +#### Minimum `libtmux~=0.39.0` (was `~=0.38.1`) + +The libtmux floor moves with the Python baseline. + +## tmuxp 1.48.0 (2024-11-26) + +tmuxp 1.48.0 moves project management from Poetry to uv and the build backend from Poetry to Hatchling. + +### Breaking changes + +#### Project management moved to uv (#949) + +[uv](https://github.com/astral-sh/uv) replaces Poetry for development workflows and dependency locking. + +#### Build backend moved to Hatchling (#949) + +[Hatchling](https://hatch.pypa.io/latest/) replaces Poetry's build backend for packaging. + +#### Minimum `libtmux~=0.38.1` (was `~=0.37.0`) (#950) + +The libtmux dependency is rebuilt and locked through the new uv-based project workflow. + +### Development + +#### Ruff 0.4.2 cleanup (#931) + +Code quality updates include f-string cleanups from the Ruff 0.4.2 rule set. + +## tmuxp 1.47.0 (2024-04-21) + +tmuxp 1.47.0 is a libtmux maintenance release. + +### Dependencies + +#### Minimum `libtmux~=0.37.0` (was `~=0.36.0`) (#929) + +The libtmux update brings upstream test-suite improvements, including pytest-xdist work and more relaxed `retry_until()` tests. + +## tmuxp 1.46.0 (2024-04-12) + +tmuxp 1.46.0 changes workspace building to respect the actual terminal size. + +### Breaking changes + +#### Workspace builder detects terminal size (#926) + +The workspace builder now uses `shutil.get_terminal_size()` for dimensions, which makes percentage-based layouts such as `main-pane-height: 67%` render proportionally. Set `TMUXP_DETECT_TERMINAL_SIZE=0` to use the old fixed-size behavior during migration. + +### Documentation + +#### Plain links are linkified + +Documentation links that were previously plain text are now automatically linkified. + +### Development + +#### Ruff and Poetry maintenance (#928) + +The release includes another Ruff cleanup pass and a Poetry 1.8.2 update. + +## tmuxp 1.45.0 (2024-03-24) + +tmuxp 1.45.0 is a code-quality maintenance release. + +### Development + +#### Ruff 0.3.4 cleanup (#922) + +The codebase was run through Ruff's automated fixes and formatter, including preview and unsafe fixes where they were accepted by maintainers. + +## tmuxp 1.44.0 (2024-03-24) + +tmuxp 1.44.0 tracks libtmux 0.36.0. + +### Dependencies + +#### Minimum `libtmux~=0.36.0` (was `~=0.35.1`) (#923) + +The libtmux bump carries internal refactoring and maintenance. + +## tmuxp 1.43.1 (2024-03-24) + +tmuxp 1.43.1 picks up libtmux multi-client fixes. + +### Dependencies + +#### Minimum `libtmux~=0.35.1` (was `~=0.35.0`) + +The update improves behavior when multiple clients are attached to a session in one server. + +## tmuxp 1.43.0 (2024-03-17) + +tmuxp 1.43.0 follows libtmux's target-handling cleanup. + +### Dependencies + +#### Minimum `libtmux~=0.35.0` (was `~=0.34.0`) (#920) + +tmuxp adapts to libtmux changes that simplify redundant target passing and window-index usage. + +## tmuxp 1.42.0 (2024-03-17) + +tmuxp 1.42.0 follows libtmux's explicit-target command behavior. + +### Dependencies + +#### Minimum `libtmux~=0.34.0` (was `~=0.33.0`) (#919) + +The libtmux update makes explicit command targets the expected path. + +## tmuxp 1.41.1 (2024-03-17) + +tmuxp 1.41.1 is a small compatibility patch after the split-window API change. + +### Development + +#### Workspace builder uses `Pane.split` + +The builder now uses the newer `Pane.split` path instead of the historical `Window.split_window` helper. + +## tmuxp 1.41.0 (2024-03-17) + +tmuxp 1.41.0 tracks libtmux's split API rename and refreshes development tooling. + +### Dependencies + +#### Minimum `libtmux~=0.33.0` (was `~=0.32.0`) (#918) + +libtmux moved the split-window behavior from `split_window()` to `split()`. The old method name is kept inline here as migration history rather than linked as current API. + +### Development + +#### Poetry 1.8.1 + +Development tooling moved to Poetry 1.8.1. + +## tmuxp 1.40.0 (2024-03-32) + +tmuxp 1.40.0 is a maintenance release for libtmux export fixes and Ruff 0.3. + +### Dependencies + +#### Minimum `libtmux~=0.32.0` (was `~=0.31.0.post0`) (#914) + +The libtmux update includes an export fix and matching lint updates. + +### Development + +#### Ruff 0.3 command shape (#913) + +CI now uses `ruff check .`, matching the Ruff 0.3 command interface. + +## tmuxp 1.39.0 (2024-02-17) + +tmuxp 1.39.0 tracks libtmux command-streamlining changes. + +### Dependencies + +#### Minimum `libtmux~=0.31.0` (was `~=0.30.2`) (#912) + +Internal tmuxp code follows libtmux renames around command helpers and active window/pane accessors. Historical names such as `attached_windows` and `attached_panes` are left as migration notes. + +## tmuxp 1.38.0 (2024-02-16) + +tmuxp 1.38.0 follows the libtmux 0.30 API-name cleanup. + +### Dependencies + +#### Minimum `libtmux~=0.30.1` (was `~=0.28.1`) (#911) + +tmuxp internal method usage was updated to match the libtmux 0.30 naming style. + +## tmuxp 1.37.1 (2024-02-15) + +tmuxp 1.37.1 is a maintenance release for libtmux docs/CI fixes and CI action updates. + +### Dependencies + +#### Minimum `libtmux~=0.28.1` (was `~=0.28.0`) + +The libtmux update is a maintenance release focused on docs and CI. + +### Development + +#### GitHub Actions updated to Node 20 + +CI actions were bumped to Node 20-compatible versions. + +## tmuxp 1.37.0 (2024-02-14) + +tmuxp 1.37.0 tracks libtmux refresh and resize improvements. + +### Dependencies + +#### Minimum `libtmux~=0.28.0` (was `~=0.27.0`) (#910) + +The libtmux update brings refresh and resize improvements used by tmuxp. + +### Development + +#### tmux 3.4 in CI (#909) + +The CI matrix now includes tmux 3.4. + +## tmuxp 1.36.0 (2024-02-07) + +tmuxp 1.36.0 is a libtmux typing maintenance release. + +### Dependencies + +#### Minimum `libtmux~=0.27.0` (was `~=0.26.0`) (#908) + +The libtmux update improves `QueryList` generic typing. + +## tmuxp 1.35.0 (2024-02-07) + +tmuxp 1.35.0 tightens linting and follows libtmux docstring maintenance. + +### Dependencies + +#### Minimum `libtmux~=0.26.0` (was `~=0.25.0`) (#906) + +The libtmux update carries docstring and linting maintenance. + +### Development + +#### Stricter Ruff rule families (#907) + +Linting now includes additional rule families for commas, builtins, and exception-message style. + +## tmuxp 1.34.0 (2023-12-21) + +tmuxp 1.34.0 is an API documentation and internal package-boundary cleanup release. + +### Breaking changes + +#### Internal modules moved under `tmuxp._internal` (#897, #900) + +`config_reader` and `_types` moved into the internal namespace. These were implementation details, and the changelog leaves the old names unlinked because they are not stable public entrypoints. + +### Dependencies + +#### Minimum `libtmux~=0.25.0` (was `~=0.24.1`) (#896) + +The libtmux update improves docstring style through pydocstyle work. + +### Documentation + +#### API docs split across pages (#898) + +API documentation was broken out into multiple pages and stale reStructuredText section headings were removed. + +## tmuxp 1.33.0 (2023-12-21) + +tmuxp 1.33.0 makes documentation and CI stricter. + +### Documentation + +#### More complete docstrings (#891) + +Functions, methods, classes, and packages received docstrings, and pydocstyle enforcement was added through Ruff. + +### Development + +#### CodeQL defaults + +CodeQL moved from an advanced configuration file to GitHub's default setup. + +## tmuxp 1.32.1 (2023-11-23) + +tmuxp 1.32.1 is a test dependency and fixture-maintenance patch. + +### Dependencies + +#### libtmux 0.24.1 and `gp-lib` test dependency + +The test dependency group gained `gp-lib`, and libtmux moved to the 0.24.1 maintenance release. + +### Development + +#### Named shell test fixtures (#893) + +Shell tests now use named, typed fixtures for clearer failures. + +## tmuxp 1.32.0 (2023-11-19) + +tmuxp 1.32.0 consolidates test configuration and moves formatting from Black to Ruff. + +### Packaging + +#### Pytest and Poetry metadata cleanup (#886) + +Pytest configuration moved into `pyproject.toml`, Python 3.12 classifiers were added, and development dependency groups were corrected to match Poetry's dependency-group model. + +### Development + +#### Ruff formatter replaces Black (#890) + +Formatting now uses [Ruff format](https://docs.astral.sh/ruff/formatter/), eliminating a separate Black dependency while keeping the same style. + +#### libtmux 0.24.0 and CI action updates + +The dependency stack moved to libtmux 0.24.0, Poetry 1.7.0, and newer GitHub Actions packages. + +## tmuxp 1.31.0 (2023-09-23) + +tmuxp 1.31.0 drops Python 3.7 and reaches strict mypy compliance. + +### Breaking changes + +#### Python 3.8+ is now required (#885) + +Python 3.7 support ends on this line. + +### Development + +#### Strict typing (#859) + +The project is now `mypy --strict` compliant, and Poetry moved to 1.6.1. + +## tmuxp 1.30.1 (2023-09-09) + +tmuxp 1.30.1 is the final Python 3.7 release line. + +### Breaking changes + +#### Python 3.7 maintenance branch + +Security updates, if needed, can target the 1.30.x branch after Python 3.7 end of life. + +## tmuxp 1.30.0 (2023-09-04) + +tmuxp 1.30.0 is a code-quality release built around Ruff. + +### Development + +#### Faster linting and import sorting with Ruff (#879) + +Ruff now handles more code-quality rules and runs quickly over the full codebase. CI also checks Black formatting. + +### Dependencies + +#### libtmux 0.23.2 + +The libtmux update is the final Python 3.7-compatible libtmux line. + +### Documentation + +#### Nix README example (#883) + +The README gained a Nix example from @ChristopherHarwell. + +## tmuxp 1.29.1 (2023-09-02) + +tmuxp 1.29.1 is a typo-fix release. + +### Fixes + +#### Documentation and dependency typo fixes (#884) + +tmuxp docs received typo fixes from @kianmeng, and libtmux moved to a typo-fix patch release. + +## tmuxp 1.29.0 (2023-08-20) + +tmuxp 1.29.0 tracks libtmux code-quality work. + +### Dependencies + +#### libtmux 0.23.0 (#882) + +The libtmux update brings upstream code-quality improvements from [libtmux#488](https://github.com/tmux-python/libtmux/pull/488). + +### Fixes + +#### Post-release libtmux comments restored + +The `v1.29.0post0` follow-up re-added comments that were accidentally dropped during formatter work. + +## tmuxp 1.28.2 (2023-08-20) + +tmuxp 1.28.2 is a packaging-maintenance patch. + +### Dependencies + +#### libtmux 0.22.2 + +The libtmux update removes `setuptools` from build-system requirements. + +## tmuxp 1.28.1 (2023-05-28) + +tmuxp 1.28.1 restores Black as a development dependency while Ruff formatting matured. + +### Development + +#### Black restored temporarily + +Black returned alongside Ruff until Ruff could fully replace it. + +## tmuxp 1.28.0 (2023-05-27) + +tmuxp 1.28.0 starts the move from the older Black/isort/flake8 stack to Ruff. + +### Development + +#### Ruff for linting, sorting, and formatting + +Ruff replaces Black, isort, flake8, and flake8 plugins for much faster whole-repo checks. The release also updates libtmux to 0.22.0 and Poetry to 1.5.0. + +## tmuxp 1.27.1 (2022-04-07) + +tmuxp 1.27.1 is a typing-maintenance patch. + +### Development + +#### mypy 1.2 and libtmux 0.21.1 + +The release updates mypy and picks up libtmux typing-only changes. + +## tmuxp 1.27.0 (2022-01-29) + +tmuxp 1.27.0 updates libtmux's tmux-format separator behavior. + +### Dependencies + +#### libtmux 0.21.0 (#865) + +The libtmux bump uses a rarer separator for tmux format output, reducing the chance of parsing collisions. See [libtmux#475](https://github.com/tmux-python/libtmux/pull/475). + +## tmuxp 1.26.0 (2023-01-15) + +tmuxp 1.26.0 improves new-session parameter support through libtmux. + +### Dependencies + +#### libtmux 0.20.0 (#863) + +The libtmux bump improves `Server.new_session` support for tmux size flags such as `-x` and `-y`. + +## tmuxp 1.25.0 (2023-01-07) + +tmuxp 1.25.0 is a libtmux patch-line update. + +### Dependencies + +#### libtmux 0.19.1 (#862) + +The update includes a fix for the historical `Window.set_window_option()` path. + +## tmuxp 1.24.1 (2023-01-07) + +tmuxp 1.24.1 improves test reliability and tracks a libtmux patch release. + +### Dependencies + +#### libtmux 0.18.3 (#861) + +The libtmux update follows [libtmux#466](https://github.com/tmux-python/libtmux/pull/466). + +### Development + +#### More reliable pane path test + +`test_pane_order` became less timing-sensitive around `pane_current_path`. + +## tmuxp 1.24.0 (2022-12-30) + +tmuxp 1.24.0 fixes session creation through libtmux and tightens test organization. + +### Dependencies + +#### libtmux 0.18.2 + +The libtmux update fixes starting new sessions at the default socket and temporary directory ([libtmux#464](https://github.com/tmux-python/libtmux/pull/464)). + +### Development + +#### CLI test and builder constructor cleanup (#857, #858) + +CLI tests were reorganized, directory resolution was fixed, and `WorkspaceBuilder` now requires an explicit `Server` in its constructor. + +## tmuxp 1.23.0 (_yanked_, 2022-12-28) + +tmuxp 1.23.0 was yanked because of `tmuxp load` issues tracked in #856. + +### Development + +#### More type annotations (#796) + +The yanked release carried additional mypy typing work. + +## tmuxp 1.22.1 (2022-12-27) + +tmuxp 1.22.1 is a documentation-only libtmux patch. + +### Dependencies + +#### libtmux 0.18.1 + +The libtmux update contains code documentation fixes. + +## tmuxp 1.22.0 (2022-12-27) + +tmuxp 1.22.0 improves {ref}`tmuxp-shell` server detection. + +### What's new + +#### `tmuxp shell` detects the current server from `TMUX` (#854) + +The shell command can now infer the active tmux server from the `TMUX` environment variable. + +## tmuxp 1.21.0 (2022-12-27) + +tmuxp 1.21.0 is a libtmux maintenance release. + +### Dependencies + +#### libtmux 0.18.0 + +The libtmux update improves `Server.__repr__`. + +## tmuxp 1.20.3 (2022-12-27) + +tmuxp 1.20.3 removes builder warning noise. + +### Fixes + +#### `_update_panes()` warnings fixed + +Builder warnings around `_update_panes()` were corrected. + +## tmuxp 1.20.2 (2022-12-27) + +tmuxp 1.20.2 is an internal libtmux deprecation-warning update. + +### Dependencies + +#### libtmux 0.17.2 + +The update carries more upstream deprecation warning coverage. + +## tmuxp 1.20.1 (2022-12-27) + +tmuxp 1.20.1 is a small libtmux and tooling maintenance release. + +### Dependencies + +#### libtmux 0.17.1 + +The update carries deprecation-warning and documentation fixes. + +### Development + +#### Poetry 1.3.1 + +Development tooling moved to Poetry 1.3.1. + +## tmuxp 1.20.0 (2022-12-26) + +tmuxp 1.20.0 adopts the libtmux 0.17 API overhaul. + +### Breaking changes + +#### libtmux 0.17 API update (#850) + +The dependency bump includes the upstream API overhaul from [libtmux#426](https://github.com/tmux-python/libtmux/pull/426). Historical method names are intentionally not linked here because this entry describes a migration point. + +### Development + +#### Automatic-rename test stabilization (#853) + +The automatic rename test received a reliability fix. + +## tmuxp 1.19.1 (2022-12-12) + +tmuxp 1.19.1 removes an indirect packaging dependency. + +### Fixes + +#### libtmux 0.16.1 removes `packaging` + +The libtmux patch removes the underlying dependency on `packaging`. + +## tmuxp 1.19.0 (2022-12-10) + +tmuxp 1.19.0 adds scoped environment variables for sessions, windows, and panes. + +### What's new + +#### Environment variables for windows and panes (#845) + +Workspace configuration can now define environment variables at the session, window, and pane levels for tmux 3.0+. See {ref}`environmental-variables` for the documented environment surface and {ref}`configuration` for workspace structure. + +### Dependencies + +#### libtmux 0.16.0 and distutils warning fixes (#727) + +The libtmux update supports the environment-variable work and removes reliance on `distutils.version.LooseVersion`. + +## tmuxp 1.18.2 (2022-11-06) + +tmuxp 1.18.2 is a maintenance release with no user-facing features or fixes. + +### Development + +#### libtmux and Poetry defaults + +libtmux moved to 0.15.10 for test tweaks, and Poetry no longer forces `in-project: true`. + +## tmuxp 1.18.1 (2022-10-31) + +tmuxp 1.18.1 fixes tmux config pass-through. + +### Fixes + +#### `tmuxp load` passes through config files (#843) + +The load command correctly forwards tmux config file arguments again. + +## tmuxp 1.18.0 (2022-10-30) + +tmuxp 1.18.0 renames the project vocabulary from "config" toward "workspace" and splits the old large modules into focused workspace modules. + +### Development + +#### Workspace package split (#840) + +Finder, freezer, importer, validation, and builder behavior moved into the `tmuxp.workspace` package. Tests moved with those boundaries, making workspace loading, freezing, importing, and validation easier to maintain separately. + +## tmuxp 1.17.3 (2022-10-30) + +tmuxp 1.17.3 adds Python 3.11 metadata and CI coverage. + +### Development + +#### Python 3.11 in the test grid (#842) + +CI, pyenv/asdf files, and package classifiers now include Python 3.11. + +## tmuxp 1.17.2 (2022-10-29) + +tmuxp 1.17.2 fixes multi-workspace loading. + +### Fixes + +#### Multiple workspace loads (#838) + +`tmuxp load` can load multiple workspace arguments correctly again, fixing #837. + +## tmuxp 1.17.1 (2022-10-15) + +tmuxp 1.17.1 improves shell completions after the argparse migration. + +### Fixes + +#### Better file completions for `tmuxp load` (#834) + +shtab completions now handle file completion for `tmuxp load` more usefully, and leftover Click completion code was removed. + +## tmuxp 1.17.0 (2022-10-09) + +tmuxp 1.17.0 replaces Click with argparse and switches completions to shtab. + +### Breaking changes + +#### Completion setup changed (#830) + +Completions are now generated with [shtab](https://docs.iterative.ai/shtab/). Users with older tmuxp completions may need to remove those before installing the new completions. See {ref}`cli-completions`. + +#### Click dependency removed (#830) + +The CLI moved from Click to argparse, so Click is no longer a runtime dependency. + +## tmuxp 1.16.2 (2022-10-08) + +tmuxp 1.16.2 fixes package metadata for YAML support. + +### Packaging + +#### YAML runtime dependency declared (#833) + +`yaml` is now included in required dependencies. Thanks @heindsight. + +## tmuxp 1.16.1 (2022-10-02) + +tmuxp 1.16.1 improves blank window-name behavior through libtmux. + +### Fixes + +#### Blank `window_name` support + +The libtmux 0.15.8 update improves handling for configurations such as `window_name: ''`. + +## tmuxp 1.16.0 (2022-10-01) + +tmuxp 1.16.0 replaces the kaptan configuration dependency with tmuxp's own typed reader. + +### Development + +#### `ConfigReader` replaces kaptan (#828) + +The new reader handles raw strings and files with a smaller typed, doctested implementation. + +### Packaging + +#### kaptan removed (#828) + +The kaptan dependency was dropped. + +## tmuxp 1.15.3 (2022-10-01) + +tmuxp 1.15.3 fixes `start_directory` behavior in the workspace builder. + +### Fixes + +#### `start_directory` builder fix (#829) + +The workspace builder now handles the reported `start_directory` case correctly. Thanks @heindsight. + +## tmuxp 1.15.2 (2022-09-24) + +tmuxp 1.15.2 fixes packaging around test configuration. + +### Packaging + +#### Root `conftest.py` kept out of wheels (#826) + +The test `conftest.py` moved to the repository root so it is not accidentally packaged in wheels. + +## tmuxp 1.15.1 (2022-09-23) + +tmuxp 1.15.1 is an infrastructure release for CI, packaging, and dependency cleanup. + +### Development + +#### Faster CI and cleaner coverage configuration (#819, #824) + +CI no longer pulls the PyPI upload image for ordinary runs, CodeQL was cleaned up, Poetry moved to 1.2, coverage configuration moved into `pyproject.toml`, and libtmux advanced through its 0.15.x pytest-plugin improvements. + +### Packaging + +#### Poetry-managed package contents + +`MANIFEST.in` was removed in favor of Poetry metadata, and the project tmuxp config no longer references `.tmuxp-before-script.sh`. + +## tmuxp 1.15.0 (2022-09-11) + +tmuxp 1.15.0 moves the project into `src/` layout. + +### Development + +#### Source layout migration (#814) + +Package code now lives under `src/`, aligning tmuxp with the surrounding Python packaging convention. + +## tmuxp 1.14.0 (2022-09-11) + +tmuxp 1.14.0 starts a maintenance series for infrastructure and API upgrades. + +### Development + +#### libtmux 0.15.1 and doctested docs + +The libtmux bump brings a major upstream retooling, including `src/` layout and doctest coverage for documentation. + +### Documentation + +#### Changelog, autodoc, and doctest helpers (#812) + +The docs gained issue-linking, a Sphinx autodoc TOC rendering fix, and documentation doctests through the gp-libs doctest helpers. + +## tmuxp 1.13.3 (2022-09-10) + +tmuxp 1.13.3 rolls back the 1.13.1 layout change while the loading behavior is studied further. + +### Fixes + +#### Layout change reverted (#811) + +The #793 layout fix was reverted so future releases can address pane layout behavior more deliberately. + +## tmuxp 1.13.2 (2022-09-10) + +tmuxp 1.13.2 adjusts layout sizing for users hitting pane spacing issues. + +### Fixes + +#### Larger default layout size (#809) + +The layout size was bumped as a mitigation for spacing issues tracked in #800. + +### Development + +#### Additional lint plugins (#807, #808) + +flake8-bugbear and flake8-comprehensions were added to the linting stack. + +## tmuxp 1.13.1 (2022-08-21) + +tmuxp 1.13.1 attempted to fix several layout-related issues. + +### Fixes + +#### Layout issue fixes (#793) + +Layout issues from #667, #704, and #737 were addressed with help from @nvasilas. + +## tmuxp 1.13.0 (2022-08-14) + +tmuxp 1.13.0 adds early typing and doctest infrastructure. + +### Development + +#### mypy, doctest, and libtmux updates (#786, #790, #791) + +The release adds basic mypy annotations, doctest support through pytest, and a libtmux update from 0.12 to 0.14. + +## tmuxp 1.12.1 (2022-08-04) + +tmuxp 1.12.1 fixes first-pane `start_directory` handling. + +### Fixes + +#### First pane `start_directory` fix (#787) + +The first pane now respects `start_directory` correctly, fixing #724. Thanks @nvasilas. + +## tmuxp 1.12.0 (2022-07-31) + +tmuxp 1.12.0 is mostly test cleanup. + +### Development + +#### Test reliability work (#774, #777, #781, #783) + +The suite was adjusted so tests finish reliably, old retry helpers moved to `retry_until()`, symlink edge cases for pane order were covered, and zsh startup files were mocked for cleaner pane output. + +## tmuxp 1.11.1 (2022-05-02) + +tmuxp 1.11.1 tightens the Click compatibility floor. + +### Fixes + +#### Click 8+ required (#775) + +The 1.11 line now requires Click 8 or newer. tmuxp 1.10 remains the Click 7-compatible line. + +## tmuxp 1.11.0 (2022-04-24) + +tmuxp 1.11.0 updates Click compatibility, shell completion behavior, and project maintenance tooling. + +### Fixes + +#### Click 8.1 and shell completion fixes (#770, #773) + +The CLI allows Click 8.1.x and fixes completions for `tmuxp load` and `tmuxp freeze`. + +### Development + +#### CLI module split and publishing cleanup (#761, #762) + +The large CLI module was split into per-command modules, tests/constants were refactored, tox was removed, package publishing moved to GitHub Actions tags, and `-V`/`--version` shows the libtmux version. + +## tmuxp 1.10.1 (2022-04-17) + +tmuxp 1.10.1 backports Click 8.1 compatibility. + +### Fixes + +#### Click 8.1 allowed (#773) + +The compatibility fix from the 1.11 line was backported to the final Python 3.7/3.8 branch. + +## tmuxp 1.10.0 (2022-03-19) + +tmuxp 1.10.0 is the final Python 3.7 and 3.8 release and a major command-execution ergonomics release. + +### Breaking changes + +#### Final Python 3.7 and 3.8 release + +Bug fixes and security updates for those Python versions moved to the [`v1.10.x`](https://github.com/tmux-python/tmuxp/tree/v1.10.x) branch. + +### What's new + +#### Command execution controls: `enter`, `sleep_before`, and `sleep_after` (#747, #750) + +Pane commands can now be sent without pressing Enter via `enter: false`, and execution can pause before or after individual commands. See {ref}`enter` and {ref}`sleep` for examples. + +#### Non-interactive freeze improvements (#701) + +{ref}`cli-freeze` gained `--quiet`, `--yes`, `--config-format`, and `--save-to`, making one-command save flows and tmux key bindings practical. + +#### Pane `shell` command support (#672) + +Pane configuration can specify a `shell` for the initial command, matching tmux's `split-window [shell-command]` behavior. Thanks @jerri. + +### Fixes + +#### `.yml` conversion support (#725) + +{ref}`cli-convert` now loads `.yml` files correctly. Thanks @kalixi. + +### Development + +#### Command parsing refactor (#752) + +Internally, command entries moved from bare strings to dictionaries so per-command options can be represented without changing normal user configuration. + +#### Tooling and docs cleanup (#738, #745) + +The codebase was formatted with Black and isort, run through pyupgrade, and the docs moved to Furo with expanded command-execution examples. + +## tmuxp 1.9.4 (2022-01-10) + +tmuxp 1.9.4 moves packaging to Poetry and adds the edit command. + +### What's new + +#### `tmuxp edit` (#707) + +The new {ref}`cli-edit` command opens workspace files from a project or config directory for editing. + +### Breaking changes + +#### Python 3.6 support removed (#726) + +Python 3.6 support ends on this release line. + +### Packaging + +#### Poetry build and publish (#729) + +Packages are built with `poetry build` and published with `poetry publish`; libtmux is pinned to a matching Poetry-built release. + +### Development + +#### Pre-commit trial and formatter updates (#726) + +The project began trying pre-commit in pull requests, updated Poetry, and moved Black to 21.12b0. + +## tmuxp 1.9.3 (2021-10-30) + +tmuxp 1.9.3 is a small CLI and tooling maintenance release. + +### Fixes + +#### Help flag and typo fixes (#696, #700) + +The CLI gained `-h`/`--help`, and documentation typos were fixed. + +### Development + +#### Poetry 1.1 update (#689) + +CI and lock files moved to Poetry 1.1.7. + +## tmuxp 1.9.2 (2021-06-17) + +tmuxp 1.9.2 allows Click 8.0.x and moves tmux manuals out of the repo. + +### Fixes + +#### Click 8.0 compatibility (#686) + +The dependency range now allows Click 8.0.x. + +### Documentation + +#### tmux manuals split out + +The old `manual/` tree moved to [tmux-python/tmux-manuals](https://github.com/tmux-python/tmux-manuals). + +## tmuxp 1.9.1 (2021-06-16) + +tmuxp 1.9.1 picks up a libtmux window-selection fix. + +### Dependencies + +#### libtmux 0.10.1+ + +The libtmux bump includes the `Window.select_window()` fix from [libtmux#271](https://github.com/tmux-python/libtmux/pull/271). + +## tmuxp 1.9.0 (2021-06-16) + +tmuxp 1.9.0 moves to libtmux 0.10.x. + +### Dependencies + +#### libtmux 0.10.x + +tmuxp tracks the current libtmux 0.10 release line. + +## tmuxp 1.8.2 (2021-06-15) + +tmuxp 1.8.2 republishes the package with missing test files restored. + +### Packaging + +#### Source distribution test files (#474) + +The release was rebuilt with `python setup.py sdist bdist_wheel` to include the missing test files. + +## tmuxp 1.8.1 (2021-06-14) + +tmuxp 1.8.1 is a Homebrew packaging helper release. + +### Packaging + +#### Version bump for Homebrew (#681) + +The version was bumped to make the Homebrew release flow easier. + +## tmuxp 1.8.0.post0 (2021-06-14) + +tmuxp 1.8.0.post0 announces the Homebrew package. + +### Packaging + +#### Homebrew availability (#681) + +tmuxp became available on Homebrew. Thanks @jvcarli. + +## tmuxp 1.8.0 (2021-06-14) + +tmuxp 1.8.0 drops Python 2.7, raises the Python floor, and cleans up plugin-test packaging. + +### Breaking changes + +#### Python 3.6+ is now required (#661) + +Python 2.7 support was removed, syntax was modernized, and Black moved to 21.6b0. + +### Development + +#### Plugin test package handling (#666) + +Test plugin packages moved from `pyproject.toml` metadata into pytest fixtures, fixing #658. + +#### Docs deploy only when changed (#662) + +CI avoids unnecessary docs updates when docs are untouched. + +## tmuxp 1.7.2 (2021-02-03) + +tmuxp 1.7.2 backports plugin test package handling. + +### Development + +#### Plugin test packages through pytest fixtures (#666) + +The 1.8 test package fix was also applied on this line. + +## tmuxp 1.7.1 (2021-02-03) + +tmuxp 1.7.1 adds support for a tmux config file flag. + +### What's new + +#### tmux config file pass-through (#665) + +tmuxp can pass `-f` to tmux for a specific configuration file. Thanks @jfindlay. + +## tmuxp 1.6.5 (2021-02-03) + +tmuxp 1.6.5 backports tmux config file support to the 1.6 line. + +### What's new + +#### tmux config file pass-through (#665) + +The `-f` config-file support from 1.7.1 was applied to the 1.6 branch. + +## tmuxp 1.7.0 (2021-01-09) + +tmuxp 1.7.0 is the last Python 2.7 release and the first stable release with plugins, append-loading, file logging, and debug information. + +### Breaking changes + +#### Last Python 2.7 release + +Bug fixes for Python 2.7 moved to the [`1.7.x`](https://github.com/tmux-python/tmuxp/tree/v1.7.x) branch. + +### What's new + +#### Plugin system (#530) + +The release adds the plugin system, tests, public plugin interface, and documentation. See {ref}`plugins`. + +#### Append windows to the current session (#656) + +`tmuxp load -a configfile` can append a workspace to the current tmux session. Thanks @will-ockmore. + +#### Load logging and debug info (#643, #647) + +`tmuxp load` can write output to a log file, and {ref}`tmuxp-debug-info` is available for collecting issue-reporting context. Thanks @joseph-flinn. + +## tmuxp 1.7.0a4 (2021-01-06) + +tmuxp 1.7.0a4 carries a Click packaging fix onto the alpha line. + +### Fixes + +#### Click package fix + +The 1.6.4 Click packaging fix was ported. + +## tmuxp 1.7.0a3 (2020-11-22) + +tmuxp 1.7.0a3 ports load logging to the alpha line. + +### What's new + +#### Load output log file + +The `tmuxp load --log-file` feature from 1.6.3 was ported. + +## tmuxp 1.7.0a2 (2020-11-08) + +tmuxp 1.7.0a2 ports debug information support to the alpha line. + +### What's new + +#### `tmuxp debug-info` + +The debug-info command from 1.6.2 was ported. + +## tmuxp 1.7.0a1 (2020-11-07) + +tmuxp 1.7.0a1 previews the plugin system. + +### What's new + +#### Plugin system (#530) + +The alpha adds plugin hooks, tests, interface code, and documentation. Thanks @joseph-flinn. + +## tmuxp 1.6.4 (2021-01-06) + +tmuxp 1.6.4 fixes a Click packaging issue. + +### Fixes + +#### Click packaging fix (#651) + +The release fixes the Click package issue tracked in #649. Thanks @dougharris. + +## tmuxp 1.6.3 (2020-11-22) + +tmuxp 1.6.3 adds log-file output to `tmuxp load`. + +### What's new + +#### Load output to a file (#647) + +`tmuxp load file.yaml --log-file yourfile.txt` writes load output to a file, and the root `--log-level` flag controls verbosity. + +## tmuxp 1.6.2 (2020-11-08) + +tmuxp 1.6.2 adds a debug-info command for issue reporting. + +### What's new + +#### `tmuxp debug-info` (#643) + +The new command collects system information for GitHub issues, fixing #352. Thanks @joseph-flinn. + +(v1-6-1)= + +## tmuxp 1.6.1 (2020-11-07) + +tmuxp 1.6.1 improves the interactive Python shell command. + +### What's new + +#### Smarter `tmuxp shell` selection (#641) + +`tmuxp shell` deprecates `shell_plus`, chooses the best available shell by default, honors `PYTHONBREAKPOINT` on Python 3.7+, and exposes explicit `--pdb`, `--code`, `--bpython`, `--ipython`, `--ptpython`, and `--ptipython` choices. + +## tmuxp 1.6.0 (2020-11-06) + +tmuxp 1.6.0 introduces {ref}`tmuxp-shell`, a Python console preloaded with the current tmux session, window, and pane. + +### What's new + +#### Python shell with tmux context (#636, #638) + +`tmuxp shell` opens an interactive Python environment with libtmux objects already available. It can also execute expressions directly with `-c`, making quick session/window/pane inspection possible from scripts. + +## tmuxp 1.5.8 (2020-10-31) + +tmuxp 1.5.8 fixes session `start_directory` propagation. + +### Fixes + +#### `start_directory` passed to new sessions (#639) + +New tmux sessions receive the configured `start_directory`, fixing #631. Thanks @joseph-flinn. + +## tmuxp 1.5.7 (2020-10-31) + +tmuxp 1.5.7 fixes project path loading for directories with periods. + +### Fixes + +#### Directory paths with periods load correctly (#637) + +`tmuxp load ~/work/your.project` now works without requiring the explicit `.tmuxp.yaml` path, fixing #212 and #201. + +## tmuxp 1.5.6 (2020-10-12) + +tmuxp 1.5.6 is a mixed freeze, prompt, session-name, docs, CI, and packaging update. + +### What's new + +#### Freeze overwrite and session-name options (#618, #626) + +`tmuxp freeze` can accept `--overwrite`, and the CLI gained a new session-name option with tests and docs. + +#### Auto-confirm prompt option (#589) + +The confirm command can auto-confirm prompts. Thanks @aRkedos. + +### Development + +#### Docs, CI, and packaging modernization (#619, #623, #629) + +Docs moved to the self-hosted site, tests moved to GitHub Actions, Makefiles were modernized, Poetry packaging experiments began, isort moved to 5.x, and Black moved to 20.08b1. + +## tmuxp 1.5.5 (2020-07-26) + +tmuxp 1.5.5 adds the `ls` command and includes a broad maintenance pass. + +### What's new + +#### `tmuxp ls` (#616) + +The new `tmuxp ls` command lists workspaces available from the config directory so they can be loaded by name. Thanks @pythops. + +### Fixes + +#### Documentation typos and CI cleanup (#480, #506, #519, #578) + +The release includes community typo fixes, Travis cleanup, cached tmux builds, tmux 2.9/3.0a testing, and the move from Pipenv to Poetry. + +## tmuxp 1.5.4 (2019-11-06) + +tmuxp 1.5.4 fixes window focus and Python 3.7 CI. + +### Fixes + +#### Window focus and Travis CI (#500) + +Window focus handling was corrected, and Travis builds were fixed for Python 3.7. + +## tmuxp 1.5.3 (2019-06-06) + +tmuxp 1.5.3 fixes the source distribution contents. + +### Packaging + +#### Examples included in sdist (#377) + +Example files are included in the source distribution. + +## tmuxp 1.5.2 (2019-06-02) + +tmuxp 1.5.2 fixes packaging constraints, XDG config handling, and freeze defaults. + +### Fixes + +#### Freeze and config-path fixes (#483, #487, #490, #491) + +`tmuxp freeze` can infer the active session, XDG `$XDG_CONFIG_HOME` handling was fixed, empty-pane configs were simplified, docs were corrected, and libtmux constraints were loosened for the 0.8.2 release. + +### Documentation + +#### CHANGES converted to plain reStructuredText (#484) + +The changelog format moved to plain reStructuredText at that point in the project history. + +## tmuxp 1.5.1 (2019-02-18) + +tmuxp 1.5.1 fixes package contents and development dependencies. + +### Packaging + +#### Test scripts included in source distributions + +Shell test scripts are included in package manifests, and Twine was added to development dependency files. + +## tmuxp 1.5.0 (2018-10-02) + +tmuxp 1.5.0 is a broad maintenance and documentation release around Click 7, XDG support, import sorting, and API docs. + +### What's new + +#### XDG base directory support (#404) + +tmuxp gained support for the XDG base directory convention. + +#### Click 7 compatibility + +The CLI supports Click 7.0. + +### Development + +#### Documentation and style modernization (#471) + +The release updates libtmux, removes stale `__future__` imports, adds isort configuration, adopts NumPy-style docstrings, updates Sphinx tooling, and expands documentation for contributors working near the load workspace path. + +## tmuxp 1.4.2 (2018-09-30) + +tmuxp 1.4.2 fixes source distribution contents. + +### Packaging + +#### Tests included in source distributions (#431) + +Test files are included in the source distribution. + +## tmuxp 1.4.1 (2018-09-26) + +tmuxp 1.4.1 loosens the Click dependency constraint. + +### Dependencies + +#### Click constraint loosened + +The Click version constraint was relaxed to `<7`. + +## tmuxp 1.4.0 (2018-03-11) + +tmuxp 1.4.0 updates licensing, CI, docs, and dependency versions. + +### Breaking changes + +#### License changed to MIT (#264) + +The project license moved from BSD to MIT. + +### Development + +#### CI and dependency refresh (#348, #349) + +Travis moved to Trusty, older Python 3 versions were removed from CI, PyPy versions were updated, flake8 entered CI, time-sensitive tests became more reliable, and Sphinx, theme, and pytest versions were bumped. + +### Dependencies + +#### libtmux 0.8.0 + +tmuxp tracks libtmux 0.8.0. + +## tmuxp 1.3.5 (2017-11-10) + +tmuxp 1.3.5 improves tmux 2.6 layout handling. + +### Fixes + +#### tmux 2.6 layout support (#308, #312) + +Layouts now set correctly across outside-tmux loads, switch-client loads, detached sessions, and background loads followed by reattach or switch. libtmux moved to 0.7.7. + +## tmuxp 1.3.4 (2017-10-12) + +tmuxp 1.3.4 fixes `before_script` working directories. + +### Fixes + +#### `before_script` respects session `start_directory` + +Pre-load scripts now run relative to the session root, which makes project setup commands such as `pipenv install` work as expected. + +## tmuxp 1.3.3 (2017-10-07) + +tmuxp 1.3.3 is a tmux 2.6 hotfix release. + +### Dependencies + +#### libtmux 0.7.5 + +The libtmux update carries a tmux 2.6 hotfix. + +## tmuxp 1.3.2 (2017-08-20) + +tmuxp 1.3.2 fixes session-scope environment variables through libtmux. + +### Fixes + +#### Session-scope environment variables (#184) + +The libtmux 0.7.4 update fixes environment variables in the session scope and refreshes pytest dependencies. + +## tmuxp 1.3.1 (2017-05-29) + +tmuxp 1.3.1 fixes ambiguous session-name matching and refreshes docs dependencies. + +### Fixes + +#### Exact session-name matching (#252) + +Loading a session whose name is a subset of another session no longer causes the wrong attach/switch behavior. + +### Dependencies + +#### libtmux 0.7.3 and docs theme update + +tmuxp updated libtmux, switched docs to the alagitpull theme, and removed unneeded docs dependencies. + +## tmuxp 1.3.0 (2017-04-27) + +tmuxp 1.3.0 adds better formatted-option support, symlinked directory support, and `options_after`. + +### What's new + +#### Formatted options, symlinked directories, and `options_after` (#235, #236, #239) + +Freezing and loading handle formatted options more accurately, symlinked directories are supported, and `options_after` can set late tmux options such as `synchronize-panes`. + +### Breaking changes + +#### Python 2.7+ baseline (#248) + +Python 2.6 support was dropped, libtmux moved to 0.7.1, and colorama was updated. + +## tmuxp 1.2.8 (2017-04-02) + +tmuxp 1.2.8 improves missing-tmux errors. + +### Fixes + +#### Helpful missing-tmux message (#229) + +Systems without `tmux` on PATH receive a clearer error message. libtmux moved from 0.6.4 to 0.6.5. + +## tmuxp 1.2.7 (2017-03-25) + +tmuxp 1.2.7 adds OpenBSD support. + +### What's new + +#### OpenBSD support + +The release adds platform support for OpenBSD. + +## tmuxp 1.2.6 (2017-02-24) + +tmuxp 1.2.6 fixes pane ordering. + +### Fixes + +#### Layout selected before splits (#218) + +Pane order is preserved by running `select-layout` before creating splits. + +## tmuxp 1.2.5 (2017-02-08) + +tmuxp 1.2.5 adds custom config-directory support and tracks tmux master. + +### What's new + +#### `TMUXP_CONFIGDIR` (#207) + +Users can choose a custom tmuxp config directory with `TMUXP_CONFIGDIR`. See {ref}`TMUXP_CONFIGDIR`. + +### Development + +#### tmux master support (#199) + +The project added support for running against tmux master and updated libtmux to 0.6.3. + +## tmuxp 1.2.4 (2017-01-13) + +tmuxp 1.2.4 is a dependency pin release. + +### Dependencies + +#### Click 6.7 and pinned docs dependencies (#195, #198) + +Click moved from 6.6 to 6.7, and colorama/docs dependencies were pinned. + +## tmuxp 1.2.3 (2016-12-21) + +tmuxp 1.2.3 is a small libtmux, test, and docs patch. + +### Fixes + +#### Suppress-history test and docs fixes (#186, #191, #193) + +The release improves suppress-history tests, fixes documentation typos, and bumps libtmux from 0.6.0 to 0.6.1. + +## tmuxp 1.2.2 (2016-09-16) + +tmuxp 1.2.2 adds tmux 2.3 support. + +### What's new + +#### tmux 2.3 support (#181) + +tmuxp now supports tmux 2.3. + +## tmuxp 1.2.1 (2016-09-16) + +tmuxp 1.2.1 fixes invalid session-name handling. + +### Fixes + +#### Invalid session names (#132) + +Invalid session names are handled correctly, and libtmux moved from 0.5.0 to 0.6.0. + +## tmuxp 1.2.0 (2016-06-16) + +tmuxp 1.2.0 adds configuration support for tmux options and environment variables. + +### What's new + +#### Session options and global options (#65) + +Workspace configuration can define `options`, `global_options`, and environment variables, with examples and tests added. + +## tmuxp 1.1.1 (2016-06-02) + +tmuxp 1.1.1 fixes attach behavior and restores version output. + +### Fixes + +#### Multiple-session attach and CLI polish (#165, #166, #167) + +Attaching multiple sessions works again, typo fixes landed, zsh/bash completion docs were added, and `tmuxp -V` returned for version info. + +## tmuxp 1.1.0 (2016-06-01) + +tmuxp 1.1.0 rewrites the CLI around Click and adds config-name loading. + +### What's new + +#### Load configs by name (#160) + +tmuxp can load named configs from the configured search paths. + +#### Click CLI rewrite (#134, #158) + +Click replaced argparse for completions, importing, config finding, conversion, and prompts. The `-l` option was removed from `tmuxp import tmuxinator|teamocil`. + +## tmuxp 1.0.2 (2016-05-25) + +tmuxp 1.0.2 fixes reattaching and improves tmuxinator imports. + +### Fixes + +#### Already-loaded session reattach (#159, #161, #163) + +Reattaching to loaded sessions was fixed, tmuxinator imports improved, and README links were corrected. + +## tmuxp 1.0.1 (2016-05-25) + +tmuxp 1.0.1 moves docs hosting and updates libtmux. + +### Documentation + +#### Read the Docs hosting + +Documentation moved to readthedocs.io. + +### Dependencies + +#### libtmux 0.4.1 (#157) + +tmuxp tracks libtmux 0.4.1. + +## tmuxp 1.0.0-rc1 (2016-05-25) + +tmuxp 1.0.0-rc1 is the release-candidate jump from the 0.11 line to 1.0. + +### What's new + +#### libtmux split and pytest migration (#145, #146, #147) + +Core tmux APIs split into [libtmux](https://github.com/tmux-python/libtmux), tests moved to pytest, new-window support landed, shell-history suppression became configurable, Makefile-based docs/test tasks were refreshed, and the README was overhauled. + +## tmuxp 0.11.0 (2016-02-29) + +tmuxp 0.11.0 adds environment settings in configs. + +### What's new + +#### Config environment settings (#137) + +Configuration files can now include environment settings. Thanks @tasdomas. + +### Documentation + +#### Spelling correction + +Spelling fixes landed from @sehe. + +## tmuxp 0.10.0 (2016-01-30) + +tmuxp 0.10.0 adds multi-session loading. + +### What's new + +#### Load multiple sessions (#135) + +tmuxp can load multiple tmux sessions at once. Thanks @madprog. + +### Documentation + +#### README and docs fixes (#131, #133) + +Documentation fixes landed alongside the feature. + +## tmuxp 0.9.3 (2016-01-06) + +tmuxp 0.9.3 improves development environment compatibility. + +### Development + +#### `.venv`, entr, and Anaconda support (#130) + +Virtualenv directories moved from `.env` to `.venv`, tests moved to [entr](http://entrproject.org/) for file watching, and Anaconda Python 2 and 3 were supported. + +## tmuxp 0.9.2 (2015-10-21) + +tmuxp 0.9.2 adds tmux 2.1 support and fixes test portability. + +### What's new + +#### tmux 2.1 support (#122) + +tmuxp now supports tmux 2.1. Thanks @estin. + +### Development + +#### Faster CI and safer pane-order tests + +Travis moved to container infrastructure, and pane-order tests stopped relying on `man(1)`. + +## tmuxp 0.9.1 (2015-08-23) + +tmuxp 0.9.1 fixes FreeBSD Python 3 packaging support. + +### Fixes + +#### FreeBSD ports Python 3 fix (#119) + +The release includes the Python 3 fix for [sysutils/pytmuxp](http://www.freshports.org/sysutils/py-tmuxp/) in FreeBSD ports. + +## tmuxp 0.9.0 (2015-07-08) + +tmuxp 0.9.0 expands shell expansion, environment-variable support, and tmux 2.0-era compatibility. + +### Breaking changes + +#### `config.expandpath` renamed to `config.expandshell` + +The old config helper was renamed as environment and shell expansion behavior grew. + +### What's new + +#### Environment variables in workspace fields + +Environment variables can be used in `start_directory`, `before_script`, `shell_command_before`, `session_name`, and `window_name`, with JSON and YAML examples added. + +### Fixes + +#### Test and dependency cleanup (#105, #107, #109, #110) + +The release devendorizes colorama, fixes Fedora pane-order tests, updates manual file extensions, fixes an attached-sessions return type, and tracks the new tmux git repository. + +## tmuxp 0.8.1 (2015-05-09) + +tmuxp 0.8.1 is a test and docs media patch. + +### Fixes + +#### Python 3 sniffer test runner + +The sniffer test runner works under Python 3, and a new animated demo was added to docs and README. + +## tmuxp 0.8.0 (2015-05-07) + +tmuxp 0.8.0 jumps from 0.1.13 to the 0.8 line for tmux 2.0 support and command API cleanup. + +### What's new + +#### tmux 2.0 support and log-level option + +The release adds tmux 2.0 support, improves `Session.switch_client()` docs, and adds `--log-level`. + +### Breaking changes + +#### `.tmux` helpers renamed to `.cmd` + +Historical `{Server,Session,Window,Pane}.tmux` helpers were refactored into `.cmd()` methods, and `util.tmux` became `util.tmux_cmd`. These old names remain inline here because they describe the 2015 API surface. + +## tmuxp 0.1.13 (2015-03-25) + +tmuxp 0.1.13 cleans up package metadata, docs building, and tests. + +### Development + +#### Metadata and docs runner cleanup + +`package_metadata.py` was replaced with `__about__.py`, docs building moved to `scent.py`, docutils moved to 0.12, `bootstrap_env.py` learned platform-specific watcher setup, CLI tests stopped using the old `TMP_DIR`, and stale watchingtestrunner examples were replaced. + +### Documentation + +#### tmux compatibility and history docs + +The release adds warnings for tmux versions below 1.4, documents leading spaces in `send_keys`, and updates the about page from teamocil and erb support. + +## tmuxp 0.1.12 (2014-08-06) + +tmuxp 0.1.12 improves path expansion when loading project files from outside the current directory. + +### Fixes + +#### Relative project file loading + +Config path expansion now resolves user and environment variables, and project files such as `/path/to/project/.tmuxp.yaml` behave better with relative directories. + +## tmuxp 0.1.11 (2014-04-06) + +tmuxp 0.1.11 improves `before_script` handling. + +### Fixes + +#### Project-relative `before_script` + +`before_script` runs relative to the project directory, reports stdout/stderr more clearly on failure, and the script-runner exceptions moved into the exception module. + +## tmuxp 0.1.10 (2014-04-02) + +tmuxp 0.1.10 fixes early pane-option and command parsing bugs. + +### Fixes + +#### Pane command and option edge cases (#73, #76, #77) + +Patches from @ThiefMaster fix spaces in `start_directory`, dashes in `shell_command`, and panes that need options but no shell command. + +## tmuxp 0.1.9 (2014-04-01) + +tmuxp 0.1.9 fixes force behavior. + +### Fixes + +#### `--force` restored + +The release restores the intended `--force` behavior. + +## tmuxp 0.1.8 (2014-03-30) + +tmuxp 0.1.8 adds pre-load scripts and directory creation. + +### What's new + +#### `before_script` and destination directory creation (#56, #72) + +tmuxp can create missing destination directories and run a `before_script` before starting a tmux session. The release also adds test helpers and links users toward the libtmux Python API quickstart. + +## tmuxp 0.1.7 (2014-02-25) + +tmuxp 0.1.7 fixes version parsing. + +### Fixes + +#### Lettered version support (#55) + +tmuxp no longer crashes on lettered tmux version strings. + +## tmuxp 0.1.6 (2014-02-08) + +tmuxp 0.1.6 stops relying on tmux `default-path` for start directories. + +### Fixes + +#### `-c start_directory` for windows and panes (#35) + +Window and pane creation now use tmux's `-c start_directory`, avoiding the old `default-path` hack that could bleed into user sessions. + +## tmuxp 0.1.5-1 (2014-02-05) + +tmuxp 0.1.5-1 fixes an installation manifest issue. + +### Packaging + +#### Manifest package metadata fix (#49) + +`package_manifest.py` is included correctly so installs no longer fail. + +## tmuxp 0.1.5 (2014-02-05) + +tmuxp 0.1.5 normalizes docs structure and packaging conventions. + +### Documentation + +#### Tao of tmux and section-heading cleanup + +The Tao of tmux docs became their own chapter candidate, headings were normalized, and project structure borrowed conventions from `tony/cookiecutter-pypackage`. + +## tmuxp 0.1.4 (2014-02-02) + +tmuxp 0.1.4 fixes CLI output and compatibility helpers. + +### Fixes + +#### Freeze output and compatibility cleanup + +`tmuxp freeze` output was corrected, `_compat` was updated, and a PEP 263 spacing issue was fixed. + +## tmuxp 0.1.3 (2014-01-29) + +tmuxp 0.1.3 fixes Python 3 CLI behavior and shell-history suppression. + +### Fixes + +#### Python 3 CLI and history suppression (#48) + +Running `tmuxp` without an option raises the expected error, Python 3 CLI behavior was fixed, and sent keys are prefixed with a space to avoid populating bash and zsh history. + +## tmuxp 0.1.2 (2014-01-08) + +tmuxp 0.1.2 adds detached loading and refreshes test style. + +### What's new + +#### Detached load mode (#43) + +`tmuxp -d` can load sessions detached. Thanks roxit. + +### Development + +#### Werkzeug/Flask-style tests + +The testsuite moved toward Werkzeug/Flask-style testing helpers. + +## tmuxp 0.1.1 (2013-12-25) + +tmuxp 0.1.1 fixes special-character handling. + +### Fixes + +#### Unicode and special characters (#32) + +Loading and freezing sessions now handle special characters more predictably. + +## tmuxp 0.1.0 (2013-12-18) + +tmuxp 0.1.0 marks the first stable 0.1 release. + +### Fixes + +#### Current-directory load output + +The duplicate filename output from `tmuxp load .` was fixed, and future releases no longer require `--pre`. + +## tmuxp 0.1-rc8 (2013-12-17) + +tmuxp 0.1-rc8 tightens Python 2/3 compatibility. + +### Development + +#### Compatibility helpers + +`unicode_literals` was adopted, and Python 2/3 compatibility code moved into `_compat`. + +## tmuxp 0.1-rc7 (2013-12-07) + +tmuxp 0.1-rc7 improves config expansion and interrupt behavior. + +### Fixes + +#### Config expansion and Ctrl-C (#33) + +The config expansion path was partially rewritten, and tmuxp exits silently on `Ctrl-C`. + +## tmuxp 0.1-rc6 (2013-12-06) + +tmuxp 0.1-rc6 adds window-index configuration. + +### What's new + +#### `window_index` option (#31) + +`window_index` support and examples were added from stratoukos. + +## tmuxp 0.1-rc5 (2013-12-04) + +tmuxp 0.1-rc5 fixes early pre-command and test behavior. + +### Fixes + +#### Session-scope `shell_command_before` and freeze errors (#26, #27, #28, #29) + +The release fixes duplicated session-scope pre-commands, improves OS X tests, and makes the missing-session error from `tmuxp freeze` less unhelpful. + +## tmuxp 0.1-rc4 (2013-12-03) + +tmuxp 0.1-rc4 fixes focused pane loading inside tmux. + +### Fixes + +#### `focus: true` inside tmux + +Focused panes no longer prevent sessions from launching when `tmuxp load` is run inside tmux. + +## tmuxp 0.1-rc3 (2013-12-03) + +tmuxp 0.1-rc3 adds focused-pane support and tests. + +### Fixes + +#### Pane focus option (#25) + +`focus: true` works for panes, tests cover focused pane config, and an example was added. + +## tmuxp 0.1-rc2 (2013-11-23) + +tmuxp 0.1-rc2 fixes pane-base-index handling. + +### Fixes + +#### `pane-base-index` set to 1 (#23) + +Workspaces build correctly when `pane-base-index` is not zero. + +### Breaking changes + +#### `tmuxp load --list` removed + +The old list behavior was removed and docs were updated. + +## tmuxp 0.1-rc1 (2013-11-23) + +tmuxp 0.1-rc1 starts per-version changelogging. + +### Development + +#### PEP 8 tests and PEP 440 versions + +Unit tests adopted PEP 8 checks, and changelog entries moved to a versioned PEP 440 scheme. + +## tmuxp 0.1-dev (2013-11-21) + +The November 21 development snapshot adds global option reads for sessions. + +### Development + +#### Session option helpers accept `g` + +`Session.show_options` and `Session.show_option` can pass `-g`. + +## tmuxp 0.1-dev (2013-11-20) + +The November 20 development snapshot improves pane ordering and option tests. + +### Fixes + +#### Pane ordering and base-index tests (#15, #21) + +Workspace builder behavior changed to fix pane ordering, Python 2.6 config tests were corrected, and async builder tests improved. + +### Development + +#### Window option helpers accept `g` + +`Window.show_window_options` and `Window.show_window_option` can pass `-g`. + +## tmuxp 0.1-dev (2013-11-17) + +The November 17 development snapshot improves missing-tmux errors. + +### Fixes + +#### Missing tmux warning + +Missing tmux now produces the correct warning. + +## tmuxp 0.1-dev (2013-11-15) + +The November 15 development snapshot makes Python 2.6 a required CI target. + +### Development + +#### Python 2.6 required in Travis + +Travis no longer treats Python 2.6 as an allowed failure. + +## tmuxp 0.1-dev (2013-11-13) + +The November 13 development snapshot adds yes-to-all prompting and duplicate-session completion protection. + +### What's new + +#### `-y` prompt confirmation (#19) + +The CLI can answer yes to questions. + +### Fixes + +#### Session completion deduplication + +The session completer no longer offers a duplicate session after one is added, and work continued on {ref}`about-tmux`. + +## tmuxp 0.1-dev (2013-11-09) + +The November 9 development snapshot adds translated docs and pane splitting at a target location. + +### Documentation + +#### Chinese documentation and about page work + +Chinese documentation was linked, and the tmux background page continued to improve. + +### Development + +#### Targeted pane splitting + +The old `Pane.split_window()` path can split a window at a target pane. + +## tmuxp 0.1-dev (2013-11-08) + +The November 8 development snapshot improves freeze output and pane shorthand. + +### What's new + +#### Freeze start directories and inline pane output + +`tmuxp freeze` can record a window `start_directory` when panes share a directory, and config exports inline simple one-command panes for more readable output. + +## tmuxp 0.1-dev (2013-11-07) + +The November 7 development snapshot replaces the logger and improves teamocil imports. + +### Development + +#### Simpler logging and teamocil root import + +The old Tornado-derived logger was replaced, teamocil import was fixed, and teamocil `root` maps to `start_directory`. + +## tmuxp 0.1-dev (2013-11-06) + +The November 6 development snapshot improves blank panes and freeze output. + +### What's new + +#### Blank panes and cleaner freeze output + +tmuxp supports blank panes (`null`, `pane`, `blank`, and empty strings), and freeze exports blank panes where it previously emitted duplicate or generic commands. + +### Development + +#### Python 2.6 support + +Python 2.6 support was restored while the project prepared to switch to per-version changelogs. + +## tmuxp 0.1-dev (2013-11-05) + +The November 5 development snapshot adds socket options to completion and loading. + +### What's new + +#### `-L` and `-S` socket support + +Autocompletion and loading support socket name and socket path arguments. Switching clients across sockets could still error in this early implementation. + +### Documentation + +#### API and about-tmux docs + +The API and {ref}`about-tmux` docs received more updates, and PEP 257 work continued. + +## tmuxp 0.1-dev (2013-11-04) + +The November 4 development snapshot is a PEP 257 and version-tag update. + +### Development + +#### PEP 257 cleanup + +Documentation style work continued, and `v0.0.36` was tagged. + +## tmuxp 0.1-dev (2013-11-02) + +The November 2 development snapshot fixes freeze paths, paths with spaces, and internal server method names. + +### Fixes + +#### Freeze, spaces, and relative `start_directory` (#12) + +`tmuxp freeze`, attach-session, kill-session, and relative `start_directory` support improved, including paths with spaces. + +### Development + +#### Internal server method cleanup + +Old double-underscore server list methods moved to single-underscore names, and docs/PEP 257 work continued. + +## tmuxp 0.1-dev (2013-11-01) + +The November 1 development snapshot expands server construction and import quality. + +### What's new + +#### Socket, config-file, and color flags + +Server construction gained `socket_name`, `socket_path`, and `config_file` support, plus `-2`/`-8` color options. + +### Fixes + +#### Relative `start_directory` and import quality + +Relative start directories concatenate correctly, teamocil and tmuxinator imports improved, and saving to `~` destinations became possible. + +## tmuxp 0.1-dev (2013-10-31) + +The October 31 development snapshot gets `start_directory` working and fixes early window ordering. + +### Fixes + +#### Window order, kill targets, and start directories + +The first and second windows no longer load in mixed order, `Window.kill_window()` targets were corrected for compatibility, and examples for `start_directory` were added. + +### Development + +#### Window moving and docs overhaul + +`Window.move_window()` landed, `util.is_version()` appeared, and the front page/internal docs were overhauled. + +## tmuxp 0.1-dev (2013-10-30) + +The October 30 development snapshot adds experimental session freezing. + +### What's new + +#### Experimental `tmuxp freeze` + +tmuxp can experimentally freeze live sessions to a file, while `tmuxp load .` and inside-tmux switching bugs were fixed. + +### Development + +#### Start directory work + +Support for `start_directory` continued, with `Window.kill_window()` work in the same snapshot. + +## tmuxp 0.1-dev (2013-10-29) + +The October 29 development snapshot adds shorthand panes, import wizards, and better crash handling. + +### What's new + +#### Pane shorthand, `.yml`, and import wizard + +Panes can be written as strings, `.yml` files load, and teamocil/tmuxinator imports gained a wizard that can save JSON or YAML. + +#### Automatic rename and pane selection + +The configuration gained `automatic-rename`, examples for pane sizing and shorthands, and pane selection accepted directional flags. + +### Fixes + +#### Loaded-session prompt and builder crash recovery + +tmuxp no longer switches or attaches after a negative prompt response, and workspace loader crashes offer kill, attach, or detach recovery options. + +## tmuxp 0.1-dev (2013-10-28) + +The October 28 development snapshot fixes current-directory load and conversion. + +### Fixes + +#### `tmuxp load .` and `tmuxp convert` + +Both commands were fixed, and test runner name handling improved. + +### Development + +#### Pane sizing and target defaults + +Historical pane sizing helpers and automatic window/pane target behavior landed during this API-building period. + +## tmuxp 0.1-dev (2013-10-27) + +The October 27 development snapshot overhauls filename completion. + +### What's new + +#### Relative and full filenames in CLI commands + +`tmuxp load`, `tmuxp convert`, and `tmuxp import` accept relative and full filenames in addition to config-directory discovery. + +### Development + +#### argcomplete CLI completion + +The CLI completion implementation was overhauled with argcomplete. + +## tmuxp 0.1-dev (2013-10-26) + +The October 26 development snapshot starts tmuxinator import support. + +### What's new + +#### Initial tmuxinator importer + +The first tmuxinator importer can convert some options, but was not expected to fully preserve window/pane size and state. + +### Fixes + +#### Config file extension handling + +Config discovery accepts extension lists and string extension arguments, and `tmuxp load -l` works alongside `tmuxp load filename`. + +## tmuxp 0.1-dev (2013-10-25) + +The October 25 development snapshot fixes version output and serverless session commands. + +### Fixes + +#### Version flag and no-server attach/kill + +`-v` and `--version` print correctly, and attach-session/kill-session handle the case where no tmux server exists. + +### Development + +#### Import fixtures + +Test fixtures and initial teamocil import work were added. + +## tmuxp 0.1-dev (2013-10-24) + +The October 24 development snapshot adds config conversion and JSON examples. + +### What's new + +#### JSON/YAML conversion + +`tmuxp convert` can convert between JSON and YAML in both directions, and JSON examples were added to the docs. + +### Fixes + +#### zsh auto-title prompts and session output + +tmuxp checks oh-my-zsh auto-title settings, fixes bad kill-session output, and improves tab completion with no active tmux server. + +## tmuxp 0.1-dev (2013-10-23) + +The October 23 development snapshot expands tab completion and core CLI commands. + +### What's new + +#### Completion for sessions and commands + +zsh, bash, and tcsh completions improved for `kill-session`, `attach-session`, and command options. `attach-session` switches clients when already inside tmux, and `load` can load configs. + +## tmuxp 0.1-dev (2013-10-21) + +The October 21 development snapshot makes tmux 1.8 the official minimum and restores version output. + +### Breaking changes + +#### tmux 1.8 minimum + +tmuxp warns when tmux is out of date and removed old compatibility code that caused build regressions. + +### Documentation + +#### Examples and graphics + +Two examples and graphics were added to {ref}`examples`, and `$ tmuxp -v` prints version info. + +## tmuxp 0.1-dev (2013-10-19) + +The October 19 development snapshot improves missing-binary handling and continues the early object-model overhaul. + +### Fixes + +#### Missing tmux exits clearly + +tmuxp exits with a clear message when `tmux` is not on PATH. + +### Development + +#### Server, session, window, and pane object refresh + +The early libtmux-style object model continued to separate public object lists from lower-level dict data returned by tmux commands. + +## tmuxp 0.1-dev (2013-10-18) + +The October 18 development snapshot moves more state refresh behavior into the server object. + +### Development + +#### Object data refresh model + +Session, window, and pane objects now refer back to server-owned data and refresh through unique identifiers, reducing manual data-setting complexity. + +#### Python 3 and legacy tmux research + +Python 3 support and possible tmux 1.6/1.7 support were under active research. + +## tmuxp 0.1-dev (2013-10-17) + +The October 17 development snapshot refreshes project docs and session switching. + +### What's new + +#### Switching sessions from inside tmux + +tmuxp can switch sessions from inside tmux after a session is built and when a session already exists. + +### Documentation + +#### README and development example update + +The README, screenshots, and development `.tmuxp.yaml` example were updated. + +## tmuxp 0.1-dev (2013-10-16) + +The October 16 development snapshot adds manifest fixes, completions, and an early object mapping layer. + +### Fixes + +#### Missing manifest file + +`MANIFEST.in` was added so packages install with required files. + +### What's new + +#### Shell completion and tmux binary lookup + +bash/zsh completion landed, and tmuxp uses a `which` helper to find the tmux binary. + +### Development + +#### Early relational object API + +Server/session/window/pane object mapping gained Backbone-inspired lookup helpers and split mapping behavior from relational behavior. + +## tmuxp 0.1-dev (2013-10-15) + +The October 15 development snapshot adds the first practical config loading flow. + +### What's new + +#### Load current-directory configs and one-command panes + +`tmuxp .` can load `.tmuxp.{yaml,json,py}` in the current directory, configs can express one-command panes compactly, and sessions prompt to attach when already present. + +#### Socket options and theme refresh + +Socket name/path support, initial examples, and a new theme landed in the same snapshot. + +## tmuxp 0.1-dev (2013-10-14) + +The October 14 development snapshot can list and build configs. + +### What's new + +#### Config listing and session building + +tmuxp can list configs in the current directory and `$HOME/.tmuxp`, launch configs, and build sessions. + +### Development + +#### Config validation helpers + +Early helpers for config consistency, config-file detection, and config-directory discovery were added. + +## tmuxp 0.1-dev (2013-10-13) + +The October 13 development snapshot improves exported config readability and object refresh helpers. + +### Development + +#### Inline config output and refresh helpers + +`config.inline()` produces cleaner exported configs, pane resizing tests were added, and session/window/pane refresh and find helpers landed. + +## tmuxp 0.1-dev (2013-10-12) + +The October 12 development snapshot introduces the workspace builder. + +### Development + +#### WorkspaceBuilder starts building sessions + +The builder can create panes, build windows, and set tmux options, with early option helpers on session and window objects. + +## tmuxp 0.1-dev (2013-10-11) + +The October 11 development snapshot prepares the builder and test runner. + +### Development + +#### Test runner, Travis matrix, and CLI beginnings + +The test suite and runner were overhauled, Travis tested tmux 1.8 and tmux source, logging was quieted, Python 3 compatibility imports were added, setup reads package version via regex, and CLI code began moving into `tmuxp.cli`. + +## tmuxp 0.1-dev (2013-10-09) + +The October 9 development snapshot simplifies logging dependencies. + +### Development + +#### Logging dependency cleanup + +A new logging module replaced the `logutils` and `sh` dependencies. + +## tmuxp 0.1-dev (2013-10-08) + +The October 8 development snapshot moves the project to semantic versioning. + +### Development + +#### Semantic versioning + +tmuxp switched to semver for early release numbering. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..8f79f960ed --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: >- + If you use this software, please cite it as below. + NOTE: Change "x.y" by the version you use. If you are unsure about which version + you are using run: `pip show tmuxp`." +authors: +- family-names: "Narlock" + given-names: "Tony" + orcid: "https://orcid.org/0000-0002-2568-415X" +title: "tmuxp" +type: software +version: x.y +url: "https://tmuxp.git-pull.com" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/LICENSE b/LICENSE index 518a264e33..203c5d283f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,29 +1,21 @@ -Copyright (c) 2013-2014 Tony Narlock and contributors (see AUTHORS file). -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: +Copyright (c) 2013- tmuxp contributors -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -* Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -* The names of the contributors may not be used to endorse or promote - products derived from this software without specific prior written - permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 97af189183..0000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include README.rst LICENSE CHANGES .tmuxp.yaml -include requirements/*.txt -recursive-include doc *.rst diff --git a/MIGRATION b/MIGRATION new file mode 100644 index 0000000000..0d31b4e377 --- /dev/null +++ b/MIGRATION @@ -0,0 +1,69 @@ +# Migration notes + +Migration and deprecation notes for tmuxp are here, see {ref}`changelog` as +well. + +```{admonition} Welcome on board! 👋 +1. 📌 For safety, **always** pin the package +2. 📖 Check the migration notes _(You are here)_ +3. 📣 If you feel something got deprecated and it interrupted you - past, present, or future - voice your opinion on the [tracker]. + + We want to make tmuxp fun, reliable, and useful for users. + + API changes can be painful. + + If we can do something to draw the sting, we'll do it. We're taking a balanced approach. That's why these notes are here! + + (Please pin the package. 🙏) + + [tracker]: https://github.com/tmux-python/tmuxp/discussions +``` + +## Next release + +_Notes on the upcoming release will be added here_ + + + +## tmuxp 1.18.0 (2022-10-30) + +**Restructuring** (#840) + +"Config files" and "configs" are now referred to as workspace files. + +Additionally, there's been a major file structure update: + +- `cli/utils.py` functions moved to `workspace/finders.py` +- `config.py` split between: + + - `workspace/finders.py` + - `workspace/freezer.py` + - `workspace/importers.py` + - `workspace/validation.py` + +- `workspacebuilder.py` split into: + + - `workspace/builder.py` + - `workspace/freezer.py` + + `config.inline` moved to freezer + +Tests: + +- `tests/fixtures/{workspacebuilder,workspacefreezer}` -> `tests/fixtures/workspace/{builder,freezer}` +- `tests/test_import_{teamocil,tmuxinator}.py` -> `tests/workspace/test_import_{teamocil,tmuxinator}.py` + +## tmuxp 1.17.0 (2022-10-09) + +**Completions have changed** (#830) + +Completions now use a different tool: [shtab]. See the [completions page] for more information. + +If you were using earlier versions of tmuxp (earlier than 1.17.0), you may need to uninstall the old completions, first. + +[completions page]: https://tmuxp.git-pull.com/cli/completion.html +[shtab]: https://docs.iterative.ai/shtab/ + + diff --git a/Makefile b/Makefile deleted file mode 100644 index c69297fd1a..0000000000 --- a/Makefile +++ /dev/null @@ -1,29 +0,0 @@ -WATCH_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]py$$' 2> /dev/null - - -test: - py.test $(test) - -entr_warn: - @echo "----------------------------------------------------------" - @echo " ! File watching functionality non-operational ! " - @echo "" - @echo "Install entr(1) to automatically run tasks on file change." - @echo "See http://entrproject.org/" - @echo "----------------------------------------------------------" - - -watch_test: - if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi - -build_docs: - cd doc && $(MAKE) html - -watch_docs: - cd doc && $(MAKE) watch_docs - -flake8: - flake8 tmuxp tests - -watch_flake8: - if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) flake8; else $(MAKE) flake8 entr_warn; fi diff --git a/README.md b/README.md new file mode 100644 index 0000000000..74befa3c7a --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# tmuxp + +Session manager for tmux. Save and load your tmux sessions through simple configuration files. Powered by [libtmux](https://github.com/tmux-python/libtmux). + +[![Python Package](https://img.shields.io/pypi/v/tmuxp.svg)](https://pypi.org/project/tmuxp/) +[![Docs](https://github.com/tmux-python/tmuxp/workflows/docs/badge.svg)](https://tmuxp.git-pull.com/) +[![Build status](https://github.com/tmux-python/tmuxp/workflows/tests/badge.svg)](https://github.com/tmux-python/tmuxp/actions?query=workflow%3A%22tests%22) +[![Code Coverage](https://codecov.io/gh/tmux-python/tmuxp/branch/master/graph/badge.svg)](https://codecov.io/gh/tmux-python/tmuxp) +[![License](https://img.shields.io/github/license/tmux-python/tmuxp.svg)](https://github.com/tmux-python/tmuxp/blob/master/LICENSE) + +**New to tmux?** [The Tao of tmux](https://leanpub.com/the-tao-of-tmux) +is available on Leanpub and [Amazon Kindle](http://amzn.to/2gPfRhC). +Read and browse the book for free [on the +web](https://leanpub.com/the-tao-of-tmux/read). + +**Have some spare time?** Help us triage and code-review on the tracker. See [issue +#290](https://github.com/tmux-python/tmuxp/discussions/290)! + +# Installation + +pip: + +```console +pip install --user tmuxp +``` + +If you're managing the project with [uv](https://docs.astral.sh/uv/), add tmuxp as a dependency instead: + +```console +uv add tmuxp +``` + +To run tmuxp without installing it globally, similar to `pipx`, invoke it with +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +uvx tmuxp +``` + +Homebrew: + +```console +brew install tmuxp +``` + +Debian / ubuntu: + +```console +sudo apt install tmuxp +``` + +Nix: + +```console +[[ -z $(which tmux) ]] && (nix-env -i tmux && nix-env -i tmuxp) || nix-env -i tmuxp +``` + +Find the package for your distro on repology: + +Developmental releases: + +- [pip](https://pip.pypa.io/en/stable/): + + ```console + pip install --user --upgrade --pre tmuxp + ``` + + Or request the pre-release from a uv project environment: + + ```console + uv add 'tmuxp>=1.10.0b1' + ``` + + +- [uvx](https://docs.astral.sh/uv/guides/tools/): + + ```console + uvx tmuxp + ``` + +- [pipx](https://pypa.github.io/pipx/docs/): + + ```console + pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force + ``` + + Then use `tmuxp@next load [session]`. + +# Load a tmux session + +Load tmux sessions via json and YAML, +[tmuxinator](https://github.com/aziz/tmuxinator) and +[teamocil](https://github.com/remiprev/teamocil) style. + +```yaml +session_name: 4-pane-split +windows: + - window_name: dev window + layout: tiled + shell_command_before: + - cd ~/ # run as a first command in all panes + panes: + - shell_command: # pane no. 1 + - cd /var/log # run multiple commands in this pane + - ls -al | grep \.log + - echo second pane # pane no. 2 + - echo third pane # pane no. 3 + - echo fourth pane # pane no. 4 +``` + +Save as _mysession.yaml_, and load: + +```console +tmuxp load ./mysession.yaml +``` + +Projects with _.tmuxp.yaml_ or _.tmuxp.json_ load via directory: + +```console +tmuxp load path/to/my/project/ +``` + +Load multiple at once (in bg, offer to attach last): + +```console +tmuxp load mysession ./another/project/ +``` + +Name a session: + +```console +tmuxp load -s session_name ./mysession.yaml +``` + +[simple](https://tmuxp.git-pull.com/configuration/examples/#short-hand-inline-style) and +[very +elaborate](https://tmuxp.git-pull.com/configuration/examples/#super-advanced-dev-environment) +config examples + +# User-level configurations + +tmuxp checks for configs in user directories: + +- `$TMUXP_CONFIGDIR`, if set +- `$XDG_CONFIG_HOME`, usually _$HOME/.config/tmuxp/_ +- `$HOME/.tmuxp/` + +Load your tmuxp config from anywhere by using the filename, assuming +_\~/.config/tmuxp/mysession.yaml_ (or _.json_): + +```console +tmuxp load mysession +``` + +See [author's tmuxp configs](https://github.com/tony/tmuxp-config) and +the projects' +[tmuxp.yaml](https://github.com/tmux-python/tmuxp/blob/master/.tmuxp.yaml). + +# Shell + +_New in 1.6.0_: + +`tmuxp shell` launches into a python console preloaded with the attached +server, session, and window in +[libtmux](https://github.com/tmux-python/libtmux) objects. + +```console +tmuxp shell + +(Pdb) server + +(Pdb) server.sessions +[Session($1 your_project)] +(Pdb) session +Session($1 your_project) +(Pdb) session.name +'your_project' +(Pdb) window +Window(@3 1:your_window, Session($1 your_project)) +(Pdb) window.name +'your_window' +(Pdb) window.panes +[Pane(%6 Window(@3 1:your_window, Session($1 your_project))) +(Pdb) pane +Pane(%6 Window(@3 1:your_window, Session($1 your_project)) +``` + +Supports [PEP +553](https://www.python.org/dev/peps/pep-0553/) `breakpoint()` +(including `PYTHONBREAKPOINT`). Also supports direct commands via `-c`: + +```console +tmuxp shell -c 'print(window.name)' +my_window + +tmuxp shell -c 'print(window.name.upper())' +MY_WINDOW +``` + +Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell/) in +the CLI docs. + +# Pre-load hook + +Run custom startup scripts (such as installing project dependencies) +before loading tmux. See the +[before_script](https://tmuxp.git-pull.com/configuration/examples/#bootstrap-project-before-launch) +example + +# Load in detached state + +You can also load sessions in the background by passing `-d` flag + +# Screenshot + +image + +# Freeze a tmux session + +Snapshot your tmux layout, pane paths, and window/session names. + +```console +tmuxp freeze session-name +``` + +See more about [freezing +tmux](https://tmuxp.git-pull.com/cli/freeze/) sessions. + +# Convert a session file + +Convert a session file from yaml to json and vice versa. + +```console +tmuxp convert filename +``` + +This will prompt you for confirmation and shows you the new file that is +going to be written. + +You can auto confirm the prompt. In this case no preview will be shown. + +```console +tmuxp convert -y filename +tmuxp convert --yes filename +``` + +# Plugin System + +tmuxp has a plugin system to allow for custom behavior. See more about +the [Plugin System](https://tmuxp.git-pull.com/topics/plugins/). + +# Debugging Helpers + +The `load` command provides a way to log output to a log file for +debugging purposes. + +```console +tmuxp load --log-file . +``` + +Collect system info to submit with a Github issue: + +```console +tmuxp debug-info +------------------ +environment: + system: Linux + arch: x86_64 + +# ... so on +``` + +# Docs / Reading material + +See the [Quickstart](https://tmuxp.git-pull.com/quickstart/). + +[Documentation](https://tmuxp.git-pull.com) homepage (also in +[中文](http://tmuxp-zh.rtfd.org/)) + +Want to learn more about tmux itself? [Read The Tao of Tmux +online](https://tmuxp.git-pull.com/about_tmux/). + +# Donations + +Your donations fund development of new features, testing and support. +Your money will go directly to maintenance and development of the +project. If you are an individual, feel free to give whatever feels +right for the value you get out of the project. + +See donation options at . + +# Project details + +- tmux support: 3.2+ +- python support: >= 3.10, pypy, pypy3 +- Source: +- Docs: +- API: +- Changelog: +- Issues: +- Test Coverage: +- pypi: +- Open Hub: +- Repology: +- License: [MIT](http://opensource.org/licenses/MIT). diff --git a/README.rst b/README.rst deleted file mode 100644 index 357d27ca2a..0000000000 --- a/README.rst +++ /dev/null @@ -1,189 +0,0 @@ -tmuxp, tmux session manager. built on `libtmux`_. - -|pypi| |docs| |build-status| |coverage| |license| - -**New to tmux?** Pre-order a copy of my new book `The Tao of tmux `_ on Leanpub, -`Amazon Kindle`_, `Apple iBooks`_, or for free `on the web`_. Scheduled for release January 2017. - -Load a tmux session -------------------- - -Load tmux sessions via json and YAML, `tmuxinator`_ and -`teamocil`_ style. - -.. code-block:: yaml - - session_name: 4-pane-split - windows: - - window_name: dev window - layout: tiled - shell_command_before: - - cd ~/ # run as a first command in all panes - panes: - - shell_command: # pane no. 1 - - cd /var/log # run multiple commands in this pane - - ls -al | grep \.log - - echo second pane # pane no. 2 - - echo third pane # pane no. 3 - - echo forth pane # pane no. 4 - -Save as ``mysession.yaml``. And load: - -.. code-block:: sh - - $ tmuxp load ./mysession.yaml - -Sessions in ``~/.tmuxp/`` can use names: - -.. code-block:: sh - - $ tmuxp load mysession - -Projects with ``.tmuxp.yaml`` or ``.tmuxp.json`` load via directory: - -.. code-block:: sh - - $ tmuxp load path/to/my/project/ - -Load multiple at once (in bg, offer to attach last): - -.. code-block:: sh - - $ tmuxp load mysession ./another/project/ - -`simple`_, `very elaborate`_ config examples - -Store configs in (``~/.tmuxp``) or include in your project as -``~/.tmuxp.{yaml,json}``. See `author's tmuxp configs`_ and the -the projects' `tmuxp.yaml`_. - -bootstrap project dependencies before loading tmux. See the -`bootstrap_env.py`_ and `before_script`_ example - -You can also load sessions in the background by passing ``-d`` flag - -.. image:: https://raw.github.com/tony/tmuxp/master/doc/_static/tmuxp-demo.gif - :scale: 100% - :width: 45% - :align: center - - -Freeze a tmux session ---------------------- - -snapshot your tmux layout, pane paths, and window/session names. - -.. code-block:: sh - - $ tmuxp freeze SESSION_NAME - -See more about `freezing tmux`_ sessions. - -Docs / Reading material ------------------------ - -See the `Quickstart`_. - -`Documentation`_ homepage (also in `中文`_) - -Want to learn more about tmux itself? `Read The Tao of Tmux online`_. - -.. _tmuxp on Travis CI: http://travis-ci.org/tony/tmuxp -.. _Documentation: http://tmuxp.git-pull.com -.. _Source: https://github.com/tony/tmuxp -.. _中文: http://tmuxp-zh.rtfd.org/ -.. _before_script: http://tmuxp.git-pull.com/en/latest/examples.html#bootstrap-project-before-launch -.. _virtualenv: https://virtualenv.git-pull.com/en/latest/ -.. _Read The Tao of tmux online: http://tmuxp.git-pull.com/en/latest/about_tmux.html -.. _author's tmuxp configs: https://github.com/tony/tmuxp-config -.. _python library: https://tmuxp.git-pull.com/en/latest/api.html -.. _python API quickstart: https://tmuxp.git-pull.com/en/latest/quickstart_python.html -.. _tmux(1): http://tmux.sourceforge.net/ -.. _tmuxinator: https://github.com/aziz/tmuxinator -.. _teamocil: https://github.com/remiprev/teamocil -.. _Examples: http://tmuxp.git-pull.com/en/latest/examples.html -.. _freezing tmux: http://tmuxp.git-pull.com/en/latest/cli.html#freeze-sessions -.. _bootstrap_env.py: https://github.com/tony/tmuxp/blob/master/bootstrap_env.py -.. _travis.yml: http://tmuxp.git-pull.com/en/latest/developing.html#travis-ci -.. _testing: http://tmuxp.git-pull.com/en/latest/developing.html#test-runner -.. _python objects: http://tmuxp.git-pull.com/en/latest/api.html#api -.. _tmuxp.yaml: https://github.com/tony/tmuxp/blob/master/.tmuxp.yaml -.. _simple: http://tmuxp.git-pull.com/en/latest/examples.html#short-hand-inline -.. _very elaborate: http://tmuxp.git-pull.com/en/latest/examples.html#super-advanced-dev-environment -.. _Quickstart: http://tmuxp.git-pull.com/en/latest/quickstart.html -.. _Commands: http://tmuxp.git-pull.com/en/latest/cli.html -.. _libtmux: https://github.com/tony/libtmux -.. _on the web: https://leanpub.com/the-tao-of-tmux/read - -Donations ---------- - -Your donations fund development of new features, testing and support. -Your money will go directly to maintenance and development of the project. -If you are an individual, feel free to give whatever feels right for the -value you get out of the project. - -See donation options at https://git-pull.com/support.html. - -Project details ---------------- - -============== ========================================================== -tmux support 1.8, 1.9a, 2.0, 2.1, 2.2, 2.3 -python support 2.6, 2.7, >= 3.3 -config support yaml, json, python dict -Source https://github.com/tony/tmuxp -Docs http://tmuxp.git-pull.com -API http://tmuxp.git-pull.com/en/latest/api.html -Changelog http://tmuxp.git-pull.com/en/latest/history.html -Issues https://github.com/tony/tmuxp/issues -Travis http://travis-ci.org/tony/tmuxp -Test Coverage https://coveralls.io/r/tony/tmuxp -pypi https://pypi.python.org/pypi/tmuxp -Open Hub https://www.openhub.net/p/tmuxp -License `BSD`_. -git repo .. code-block:: bash - - $ git clone https://github.com/tony/tmuxp.git -install stable .. code-block:: bash - - $ sudo pip install tmuxp -install dev .. code-block:: bash - - $ git clone https://github.com/tony/tmuxp.git tmuxp - $ cd ./tmuxp - $ virtualenv .venv - $ source .venv/bin/activate - $ pip install -e . - - See the `developing and testing`_ page in the docs for - more. -tests .. code-block:: bash - - $ make test -============== ========================================================== - -.. _BSD: http://opensource.org/licenses/BSD-3-Clause -.. _developing and testing: http://tmuxp.git-pull.com/en/latest/developing.html -.. _Amazon Kindle: http://amzn.to/2gPfRhC -.. _Apple iBooks: https://geo.itunes.apple.com/us/book/the-tao-of-tmux/id1168912720?mt=11&at=1001lrwP - -.. |pypi| image:: https://img.shields.io/pypi/v/tmuxp.svg - :alt: Python Package - :target: http://badge.fury.io/py/tmuxp - -.. |build-status| image:: https://img.shields.io/travis/tony/tmuxp.svg - :alt: Build Status - :target: https://travis-ci.org/tony/tmuxp - -.. |coverage| image:: https://codecov.io/gh/tony/tmuxp/branch/master/graph/badge.svg - :alt: Code Coverage - :target: https://codecov.io/gh/tony/tmuxp - -.. |license| image:: https://img.shields.io/github/license/tony/tmuxp.svg - :alt: License - -.. |docs| image:: https://readthedocs.org/projects/tmuxp/badge/?version=latest - :alt: Documentation Status - :scale: 100% - :target: https://readthedocs.org/projects/tmuxp/ diff --git a/bootstrap_env.py b/bootstrap_env.py deleted file mode 100755 index 05457388a8..0000000000 --- a/bootstrap_env.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python - -from __future__ import absolute_import, print_function, unicode_literals - -import os -import subprocess -import sys - - -def warning(*objs): - print("WARNING: ", *objs, file=sys.stderr) - - -def fail(message): - sys.exit("Error: {message}".format(message=message)) - - -def has_module(module_name): - try: - import imp - imp.find_module(module_name) - del imp - return True - except ImportError: - return False - - -def which(exe=None, throw=True): - """Return path of bin. Python clone of /usr/bin/which. - - from salt.util - https://www.github.com/saltstack/salt - license apache - - :param exe: Application to search PATHs for. - :type exe: string - :param throw: Raise ``Exception`` if not found in paths - :type throw: bool - :rtype: string - - """ - if exe: - if os.access(exe, os.X_OK): - return exe - - # default path based on busybox's default - default_path = '/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin' - search_path = os.environ.get('PATH', default_path) - - for path in search_path.split(os.pathsep): - full_path = os.path.join(path, exe) - if os.access(full_path, os.X_OK): - return full_path - - message = ( - '{0!r} could not be found in the following search ' - 'path: {1!r}'.format( - exe, search_path - ) - ) - - if throw: - raise Exception(message) - else: - print(message) - return None - - -project_dir = os.path.dirname(os.path.realpath(__file__)) -env_dir = os.path.join(project_dir, '.venv') -pip_bin = os.path.join(env_dir, 'bin', 'pip') -python_bin = os.path.join(env_dir, 'bin', 'python') -virtualenv_bin = which('virtualenv', throw=False) -virtualenv_exists = os.path.exists(env_dir) and os.path.isfile(python_bin) -sphinx_requirements_filepath = os.path.join( - project_dir, 'requirements', 'doc.txt') -test_requirements_filepath = os.path.join( - project_dir, 'requirements', 'test.txt') - - -try: - import virtualenv # NOQA -except ImportError: - message = ( - 'Virtualenv is required for this bootstrap to run.\n' - 'Install virtualenv via:\n' - '\t$ [sudo] pip install virtualenv' - ) - fail(message) - - -try: - import pip # NOQA -except ImportError: - message = ( - 'pip is required for this bootstrap to run.\n' - 'Find instructions on how to install at: %s' % - 'http://pip.readthedocs.io/en/latest/installing.html' - ) - fail(message) - - -def main(): - if not which('entr', throw=False): - message = ( - '\nentr(1) is used in this app as a cross platform file watcher.' - 'You can install it via your package manager on most POSIX ' - 'systems. See the site at http://entrproject.org/\n' - ) - print(message) - - if not virtualenv_exists: - virtualenv_bin = which('virtualenv', throw=False) - - subprocess.check_call( - [virtualenv_bin, env_dir] - ) - - subprocess.check_call( - [pip_bin, 'install', '-e', project_dir] - ) - - if not has_module('pytest'): - subprocess.check_call( - [pip_bin, 'install', '-r', test_requirements_filepath] - ) - - if not os.path.isfile(os.path.join(env_dir, 'bin', 'sphinx-quickstart')): - subprocess.check_call( - [pip_bin, 'install', '-r', sphinx_requirements_filepath] - ) - - if os.path.exists(os.path.join(env_dir, 'build')): - os.removedirs(os.path.join(env_dir, 'build')) - -if __name__ == '__main__': - main() diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..ad606b5342 --- /dev/null +++ b/conftest.py @@ -0,0 +1,151 @@ +"""Conftest.py (root-level). + +We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well +as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin +for pytester only being available via the root directory. + +See "pytest_plugins in non-top-level conftest files" in +https://docs.pytest.org/en/stable/deprecations.html +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +import typing as t + +import pytest +from _pytest.doctest import DoctestItem +from libtmux.test.random import namer + +from tests.fixtures import utils as test_utils +from tmuxp.workspace.finders import get_workspace_dir + +if t.TYPE_CHECKING: + from libtmux.session import Session + +logger = logging.getLogger(__name__) +USING_ZSH = "zsh" in os.getenv("SHELL", "") + + +@pytest.fixture(autouse=True) +def _pin_test_shell_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin ``$SHELL`` to ``/bin/sh`` for every test. + + tmux falls back to ``$SHELL`` for new panes when ``default-shell`` is + unset, so pinning the env var here forces every test pane to spawn + ``/bin/sh`` instead of the contributor's interactive shell. Eliminates + rcfile init cost (zsh / oh-my-zsh / bash profile chains) and yields + deterministic prompt output that doesn't flake capture-pane assertions. + ``monkeypatch`` restores the prior value at teardown. + """ + monkeypatch.setenv("SHELL", "/bin/sh") + + +@pytest.fixture(autouse=USING_ZSH, scope="session") +def zshrc(user_path: pathlib.Path) -> pathlib.Path | None: + """Quiets ZSH default message. + + Needs a startup file .zshenv, .zprofile, .zshrc, .zlogin. + """ + if not USING_ZSH: + return None + p = user_path / ".zshrc" + p.touch() + return p + + +@pytest.fixture(autouse=True) +def home_path_default(monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path) -> None: + """Set HOME to user_path (random, temporary directory).""" + monkeypatch.setenv("HOME", str(user_path)) + + +@pytest.fixture +def tmuxp_configdir(user_path: pathlib.Path) -> pathlib.Path: + """Ensure and return tmuxp config directory.""" + xdg_config_dir = user_path / ".config" + xdg_config_dir.mkdir(exist_ok=True) + + tmuxp_configdir = xdg_config_dir / "tmuxp" + tmuxp_configdir.mkdir(exist_ok=True) + return tmuxp_configdir + + +@pytest.fixture +def tmuxp_configdir_default( + monkeypatch: pytest.MonkeyPatch, + tmuxp_configdir: pathlib.Path, +) -> None: + """Set tmuxp configuration directory for ``TMUXP_CONFIGDIR``.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmuxp_configdir)) + assert get_workspace_dir() == str(tmuxp_configdir) + + +@pytest.fixture +def monkeypatch_plugin_test_packages(monkeypatch: pytest.MonkeyPatch) -> None: + """Monkeypatch tmuxp plugin fixtures to python path.""" + paths = [ + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/", + ] + for path in paths: + monkeypatch.syspath_prepend(str(pathlib.Path(path).resolve())) + + +@pytest.fixture +def session_params(session_params: dict[str, t.Any]) -> dict[str, t.Any]: + """Terminal-friendly tmuxp session_params for dimensions.""" + session_params.update({"x": 800, "y": 600}) + return session_params + + +@pytest.fixture +def socket_name(request: pytest.FixtureRequest) -> str: + """Random socket name for tmuxp.""" + return f"tmuxp_test{next(namer)}" + + +# Modules that actually need tmux fixtures in their doctests +DOCTEST_NEEDS_TMUX = { + "tmuxp.workspace.builder.classic", +} + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Add rerun markers to tmux-dependent doctests for flaky shell timing.""" + for item in items: + if isinstance(item, DoctestItem): + module_name = item.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX: + item.add_marker(pytest.mark.flaky(reruns=2)) + + +@pytest.fixture(autouse=True) +def add_doctest_fixtures( + request: pytest.FixtureRequest, + doctest_namespace: dict[str, t.Any], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Harness pytest fixtures to doctests namespace.""" + if isinstance(request._pyfuncitem, DoctestItem): + # Always provide lightweight fixtures + doctest_namespace["test_utils"] = test_utils + doctest_namespace["tmp_path"] = tmp_path + doctest_namespace["monkeypatch"] = monkeypatch + + # Only load expensive tmux fixtures for modules that need them + module_name = request._pyfuncitem.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX and shutil.which("tmux"): + doctest_namespace["server"] = request.getfixturevalue("server") + session: Session = request.getfixturevalue("session") + doctest_namespace["session"] = session + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index d5a598b0e6..0000000000 --- a/doc/Makefile +++ /dev/null @@ -1,165 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tmuxp.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tmuxp.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/tmuxp" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tmuxp" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -checkbuild: - rm -rf $(BUILDDIR) - $(SPHINXBUILD) -n -q ./ $(BUILDDIR) - -WATCH_FILES= find .. -type f -not -path '*/\.*' | grep -i '.*[.]rst\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null - -watch: - if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) html; else $(MAKE) html; fi - -serve: - echo 'docs built to '; python -m SimpleHTTPServer 8003 diff --git a/doc/_ext/aafig.py b/doc/_ext/aafig.py deleted file mode 100644 index 17cd661d14..0000000000 --- a/doc/_ext/aafig.py +++ /dev/null @@ -1,210 +0,0 @@ -# -*- coding: utf-8 -*- -""" - sphinxcontrib.aafig - ~~~~~~~~~~~~~~~~~~~ - - Allow embeded ASCII art to be rendered as nice looking images - using the aafigure reStructuredText extension. - - See the README file for details. - - :author: Leandro Lucarella - :license: BOLA, see LICENSE for details -""" - -import posixpath -from os import path - -from docutils import nodes -from docutils.parsers.rst.directives import flag, images, nonnegative_int -from sphinx.errors import SphinxError -from sphinx.util import ensuredir, relative_uri - -try: - from hashlib import sha1 as sha -except ImportError: - from sha import sha - - -try: - import aafigure -except ImportError: - aafigure = None - - -DEFAULT_FORMATS = dict(html='svg', latex='pdf', text=None) - - -def merge_dict(dst, src): - for (k, v) in src.items(): - if k not in dst: - dst[k] = v - return dst - - -def get_basename(text, options, prefix='aafig'): - options = options.copy() - if 'format' in options: - del options['format'] - hashkey = text.encode('utf-8') + str(options) - id = sha(hashkey).hexdigest() - return '%s-%s' % (prefix, id) - - -class AafigError(SphinxError): - category = 'aafig error' - - -class AafigDirective(images.Image): - """ - Directive to insert an ASCII art figure to be rendered by aafigure. - """ - has_content = True - required_arguments = 0 - own_option_spec = dict( - line_width=float, - background=str, - foreground=str, - fill=str, - aspect=nonnegative_int, - textual=flag, - proportional=flag, - ) - option_spec = images.Image.option_spec.copy() - option_spec.update(own_option_spec) - - def run(self): - aafig_options = dict() - own_options_keys = self.own_option_spec.keys() + ['scale'] - for (k, v) in self.options.items(): - if k in own_options_keys: - # convert flags to booleans - if v is None: - v = True - # convert percentage to float - if k == 'scale' or k == 'aspect': - v = float(v) / 100.0 - aafig_options[k] = v - del self.options[k] - self.arguments = [''] - (image_node,) = images.Image.run(self) - if isinstance(image_node, nodes.system_message): - return [image_node] - text = '\n'.join(self.content) - image_node.aafig = dict(options=aafig_options, text=text) - return [image_node] - - -def render_aafig_images(app, doctree): - format_map = app.builder.config.aafig_format - merge_dict(format_map, DEFAULT_FORMATS) - if aafigure is None: - app.builder.warn('aafigure module not installed, ASCII art images ' - 'will be redered as literal text') - for img in doctree.traverse(nodes.image): - if not hasattr(img, 'aafig'): - continue - if aafigure is None: - continue - options = img.aafig['options'] - text = img.aafig['text'] - format = app.builder.format - merge_dict(options, app.builder.config.aafig_default_options) - if format in format_map: - options['format'] = format_map[format] - else: - app.builder.warn('unsupported builder format "%s", please ' - 'add a custom entry in aafig_format config ' - 'option for this builder' % format) - img.replace_self(nodes.literal_block(text, text)) - continue - if options['format'] is None: - img.replace_self(nodes.literal_block(text, text)) - continue - try: - fname, outfn, id, extra = render_aafigure(app, text, options) - except AafigError, exc: - app.builder.warn('aafigure error: ' + str(exc)) - img.replace_self(nodes.literal_block(text, text)) - continue - img['uri'] = fname - # FIXME: find some way to avoid this hack in aafigure - if extra: - (width, height) = [x.split('"')[1] for x in extra.split()] - if 'width' not in img: - img['width'] = width - if 'height' not in img: - img['height'] = height - - -def render_aafigure(app, text, options): - """ - Render an ASCII art figure into the requested format output file. - """ - - if aafigure is None: - raise AafigError('aafigure module not installed') - - fname = get_basename(text, options) - fname = '%s.%s' % (get_basename(text, options), options['format']) - if app.builder.format == 'html': - # HTML - imgpath = relative_uri(app.builder.env.docname, '_images') - relfn = posixpath.join(imgpath, fname) - outfn = path.join(app.builder.outdir, '_images', fname) - else: - # Non-HTML - if app.builder.format != 'latex': - app.builder.warn('aafig: the builder format %s is not officially ' - 'supported, aafigure images could not work. ' - 'Please report problems and working builder to ' - 'avoid this warning inthe future' % - app.builder.format) - relfn = fname - outfn = path.join(app.builder.outdir, fname) - metadata_fname = '%s.aafig' % outfn - - try: - if path.isfile(outfn): - extra = None - if options['format'].lower() == 'svg': - f = None - try: - try: - f = file(metadata_fname, 'r') - extra = f.read() - except: - raise AafigError() - finally: - if f is not None: - f.close() - return relfn, outfn, id, extra - except AafigError: - pass - - ensuredir(path.dirname(outfn)) - - try: - (visitor, output) = aafigure.render(text, outfn, options) - output.close() - except aafigure.UnsupportedFormatError, e: - raise AafigError(str(e)) - - extra = None - if options['format'].lower() == 'svg': - extra = visitor.get_size_attrs() - f = file(metadata_fname, 'w') - f.write(extra) - f.close() - - return relfn, outfn, id, extra - - -def setup(app): - app.add_directive('aafig', AafigDirective) - app.connect('doctree-read', render_aafig_images) - app.add_config_value('aafig_format', DEFAULT_FORMATS, 'html') - app.add_config_value('aafig_default_options', dict(), 'html') - - -# vim: set expandtab shiftwidth=4 softtabstop=4 : diff --git a/doc/_static/tmuxp.css b/doc/_static/tmuxp.css deleted file mode 100644 index 0a69b44293..0000000000 --- a/doc/_static/tmuxp.css +++ /dev/null @@ -1,39 +0,0 @@ -div.sidebar { - margin: 0px; - border: 0px; - padding: 0px; - background-color: inherit; -} - -div.sidebar .sidebar-title { - display: none; -} - -div.sidebar { - font-size: 13px; -} - -div.sidebar img { - margin: 0px; - padding: 0px; - width: 50%; - text-align: center; -} - -form.navbar-form { - padding: 0px 10px; -} - -div#changelog > div.section > ul > li > p:only-child { - margin-bottom: 0; -} - -div#text-based-window-manager { - clear: both; -} - -@media screen and (max-width: 768px) { - #fork-gh img { - display: none; - } -} diff --git a/doc/_templates/book.html b/doc/_templates/book.html deleted file mode 100644 index d0bf029364..0000000000 --- a/doc/_templates/book.html +++ /dev/null @@ -1,11 +0,0 @@ -

Book Pre-order

- -

- -

Get your copy of the The Tao of tmux on Leanpub, Kindle (Amazon), -and iBooks (iTunes store). The final version will be delivered to you January 2017.

-

Pre-order on Leanpub before Dec 23rd for the special discount -price of $7.99.

-

Read and browse the book for free on the web.

-Amazon Kindle -iTunes diff --git a/doc/_templates/layout.html b/doc/_templates/layout.html deleted file mode 100644 index 022f011cab..0000000000 --- a/doc/_templates/layout.html +++ /dev/null @@ -1,5 +0,0 @@ -{# Import the theme's layout. #} -{% extends "!layout.html" %} - -{# Include our new CSS file into existing ones. #} -{% set css_files = css_files + ['_static/tmuxp.css']%} diff --git a/doc/_templates/more.html b/doc/_templates/more.html deleted file mode 100644 index 81a1882d46..0000000000 --- a/doc/_templates/more.html +++ /dev/null @@ -1,17 +0,0 @@ -

Other Projects

- -

- -

More open source projects from Tony Narlock:

- - - - - Fork me on GitHub - diff --git a/doc/_templates/star.html b/doc/_templates/star.html deleted file mode 100644 index 0f7feffeb5..0000000000 --- a/doc/_templates/star.html +++ /dev/null @@ -1,4 +0,0 @@ -

- -

diff --git a/doc/about.rst b/doc/about.rst deleted file mode 100644 index 73163aded1..0000000000 --- a/doc/about.rst +++ /dev/null @@ -1,118 +0,0 @@ -.. module:: tmuxp - -.. _about: - -===== -About -===== - -tmuxp helps you manage tmux workspaces. - -Built on a object relational mapper for tmux. tmux users can reload common -workspaces from YAML, JSON and :py:obj:`dict` configurations like -`tmuxinator`_ and `teamocil`_. - -tmuxp is used by developers for tmux automation at great companies like -`Bugsnag`_, `Pragmatic Coders`_ and many others. - -To jump right in, see :ref:`quickstart` and :ref:`examples`. - -Interested in some kung-fu or joining the effort? :ref:`api` and -:ref:`developing`. - -`BSD-licensed`_. Code on `github -`_. - -.. _Bugsnag: https://blog.bugsnag.com/benefits-of-using-tmux/ -.. _Pragmatic Coders: http://pragmaticcoders.com/blog/tmuxp-preconfigured-sessions/ - -Differences from tmuxinator / teamocil --------------------------------------- - -.. note:: - - If you use teamocil / tmuxinator and can clarify or add differences, - please free to `edit this page`_ on github. - -Similarities -~~~~~~~~~~~~ - -**Load sessions** Loads tmux sessions from config - -**YAML** Supports YAML format - -**Inlining / shorthand configuration** All three support short-hand and -simplified markup for panes that have one command. - -**Maturity and stability** As of 2016, all three are considered stable, -well tested and adopted. - -Missing -~~~~~~~ - -**Version support** tmuxp only supports ``tmux >= 1.8``. Teamocil and -tmuxinator may have support for earlier versions. - -Differences -~~~~~~~~~~~ - -**Programming Language** python. teamocil and tmuxinator uses ruby. - -**Workspace building process** teamocil and tmuxinator process configs -directly shell commands. tmuxp processes configuration via ORM layer. - -Additional Features -------------------- - -**CLI** tmuxp's CLI can attach and kill sessions with tab-completion -support. See :ref:`commands`. - -**Import config** import configs from Teamocil / Tmuxinator [1]_. See -:ref:`cli_import`. - -**Session freezing** Supports session freezing into YAML and JSON -format [1]_. See :ref:`cli_freeze`. - -**JSON config** JSON config support. See :ref:`Examples`. - -**ORM-based API** - Utilitizes tmux >= 1.8's unique ID's for panes, -windows and sessions to create an object relational view of the tmux -:class:`Server` and its' :class:`Session`, :class:`Window`, :class:`Pane`. -See :ref:`Internals`. - -**Conversion** ``$ tmuxp convert `` can convert files to and -from JSON and YAML. - -.. [1] While freezing and importing sessions is a great way to save time, - tweaking will probably be required - There is no substitute to a - config made with love. - -Minor tweaks ------------- - -- Unit tests against live tmux version to test statefulness of tmux - sessions, windows and panes. See :ref:`travis`. -- Load + switch to new session from inside tmux. -- Resume session if config loaded. -- Pre-commands virtualenv / rvm / any other commands. -- Load config from anywhere ``$ tmuxp load /full/file/path.json``. -- Load config ``.tmuxp.yaml`` or ``.tmuxp.json`` from current working - directory with ``$ tmuxp load .``. -- ``$ tmuxp -2``, ``$ tmuxp -8`` for forcing term colors a la - :term:`tmux(1)`. -- ``$ tmuxp -L``, ``$ tmuxp -S`` for sockets and - ``$ tmuxp -f`` for config file. - -More details ------------- - -.. include:: ../README.rst - :start-after: --------------- - -.. _attempt at 1.7 test: https://travis-ci.org/tony/tmuxp/jobs/12348263 -.. _kaptan: https://github.com/emre/kaptan -.. _BSD-licensed: http://opensource.org/licenses/BSD-2-Clause -.. _tmuxinator: https://github.com/aziz/tmuxinator -.. _teamocil: https://github.com/remiprev/teamocil -.. _ERB: http://ruby-doc.org/stdlib-2.0.0/libdoc/erb/rdoc/ERB.html -.. _edit this page: https://github.com/tony/tmuxp/edit/master/doc/about.rst diff --git a/doc/about_tmux.rst b/doc/about_tmux.rst deleted file mode 100644 index 27779933d1..0000000000 --- a/doc/about_tmux.rst +++ /dev/null @@ -1,659 +0,0 @@ -.. _about_tmux: - -############### -The Tao of tmux -############### - - -.. figure:: _static/tao-tmux-screenshot.png - :scale: 60% - :align: center - - BSD-licensed terminal multiplexer. - -tmux is geared for developers and admins who interact regularly with -CLI (text-only interfaces) - -In the world of computers, there are 2 realms: - -1. The text realm -2. The graphical realm - -Tmux resides in the text realm. This is about fixed-width fonts and that -old fashioned black terminal. - -tmux is to the console what a desktop is to gui apps. It's a world inside -the text dimension. Inside tmux you can: - -- multitask inside the terminal, run multiple applications. -- have multiple command lines (pane) in the same window -- have multiple windows (window) in the workspace (session) -- switch between multiple workspaces, like virtual desktops - -============= -Thinking Tmux -============= - -Text-based window manager -------------------------- - -=================== ====================== =============================== -**tmux** **"Desktop"-Speak** **Plain English** -------------------- ---------------------- ------------------------------- -Multiplexer Multi-tasking Multiple applications - simultaneously. -Session Desktop Applications are visible here -Window Virtual Desktop or A desktop that stores it own - applications screen -Pane Application Performs operations -=================== ====================== =============================== - -.. aafig:: - :textual: - - +----------------------------------------------------------------+ - | +--------+--------+ +-----------------+ +-----------------+ | - | | pane | pane | | pane | | pane | | - | | | | | | | | | - | | | | | | | | | - | +--------+--------+ | | +-----------------+ | - | | pane | pane | | | | pane | | - | | | | | | | | | - | | | | | | | | | - | +--------+--------+ +-----------------+ +-----------------+ | - | | window | | window | | window | | - | \--------+--------/ \-----------------/ \-----------------/ | - +----------------------------------------------------------------+ - | session | - \----------------------------------------------------------------/ - -- 1 :term:`Server`. - - - has 1 or more :term:`Session`. - - - has 1 or more :term:`Window`. - - - has 1 or more :term:`Pane`. - -.. seealso:: :ref:`glossary` has a dictionary of tmux words. - -CLI Power Tool --------------- - -Multiple applications or terminals to run on the same screen by splitting -up 1 terminal into multiple. - -One screen can be used to edit a file, and another may be used to -``$ tail -F`` a logfile. - -.. aafig:: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - | | | - | | | - | | | - | | | - +--------+--------+ - -.. aafig:: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - +--------+--------+ - | $ vim | $ bash | - | | | - | | | - +--------+--------+ - -tmux supports as manys terminals as you want. - - -.. aafig:: - :textual: - - +---------+---------+ - | $ bash | $ bash | - | | | - | | | /-----------------\ - +---------+---------+ --> |'switch-window 2'| - | $ bash | $ bash | \-----------------/ - | | | | - | | | | - +---------+---------+ | - | '1:sys* 2:vim' | | - +-------------------+ | - /------------------------/ - | - v - +---------+---------+ - | $ vim | - | | - | | - +-------------------+ - | $ bash | $ bash | - | | | - | | | - +-------------------+ - | '1:sys 2:vim*' | - +-------------------+ - -You can switch between the windows you create. - -Resume everything later ------------------------ - -You can leave tmux and all applications running (detach), log out, make a -sandwich, and re-(attach), all applications are still running! - -.. aafig:: - :textual: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | /------------\ - +--------+--------+ --> | detach | - | $ vim | $ bash | | 'Ctrl-b b' | - | | | \------------/ - | | | | - +--------+--------+ | - /------------------/ - | - v - +-----------------------+ - | $ [screen detached] | - | | - | | - | | - | | - | | - | | - +-----------------------+ - v - | - v - +-----------------------+ - | $ [screen detached] | - | $ tmux attach | - | | /------------\ - | | --> | attaching | - | | \------------/ - | | | - | | | - +-----------------------+ | - | - /---------------------------/ - | - v - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - +--------+--------+ - | $ vim | $ bash | - | | | - | | | - +--------+--------+ - -Manage workflow ---------------- - -- System administrators monitor logs and services. -- Programmers like to have an editor open with a CLI nearby. - -Applications running on a remote server can be launched inside of a tmux -session, detached, and reattached next timeyour `"train of thought"`_ and -work. - -Multitasking. Preserving the thinking you have. - -.. _"train of thought": http://en.wikipedia.org/wiki/Train_of_thought - -=============== -Installing tmux -=============== - -Tmux is packaged on most Linux and BSD systems. - -For the freshest results on how to get tmux installed on your system, -"How to install tmux on " will do, as directions change and are -slightly different between distributions. - -This documentation is written for version **1.8**. It's important that -you have the latest stable release of tmux. The latest stable version is -viewable on the `tmux homepage`_. - -**Mac OS X** users may install that latest stable version of tmux through -`MacPorts`_, `fink`_ or `Homebrew`_ (aka brew). - -If **compiling from source**, the dependencies are `libevent`_ and -`ncurses`_. - -.. _tmux homepage: http://tmux.sourceforge.net/ -.. _libevent: http://www.monkey.org/~provos/libevent/ -.. _ncurses: http://invisible-island.net/ncurses/ -.. _MacPorts: http://www.macports.org/ -.. _Fink: http://fink.thetis.ig42.org/ -.. _Homebrew: http://www.brew.sh - -========== -Using tmux -========== - -Start a new session -------------------- - -.. code-block:: bash - - $ tmux - -That's all it takes to launch yourself into a tmux session. - -.. tip:: Common pitfall - - Running ``$ tmux list-sessions`` or any other command for listing tmux - entities (such as ``$ tmux list-windows`` or ``$ tmux list-panes``). - This can generate the error "failed to connect to server". - - This could be because: - - - tmux server has killed its' last session, killing the server. - - tmux server has encountered a crash. (tmux is highly stable, - this will rarely happen) - - tmux has not be launched yet at all. - -.. _Prefix key: - -The prefix key --------------- - -Tmux hot keys have to be pressed in a special way. **Read this -carefully**, then try it yourself. - -First, you press the *prefix* key. This is ``C-b`` by default. - -Release. Then pause. For less than second. Then type what's next. - -``C-b o`` means: Press ``Ctrl`` and ``b`` at the same time. Release, -Then press ``o``. - -**Remember, prefix + short cut!** ``C`` is ``Ctrl`` key. - -Session Name ------------- - -Sessions can be *named upon creation*. - -.. code-block:: bash - - $ tmux new-session [-s session-name] - -Sessions can be *renamed after creation*. - -=============== ========================================================= -Command .. code-block:: bash - - $ tmux rename-session - -Short cut ``Prefix`` + ``$`` -=============== ========================================================= - -Window Name ------------ - -Windows can be *named upon creation*. - -.. code-block:: bash - - $ tmux new-window [-n window-name] - -Windows can be *renamed after creation*. - -=============== ========================================================== -Command .. code-block:: bash - - $ tmux rename-window - -Short cut ``Prefix`` + ``,`` -=============== ========================================================== - -Creating new windows --------------------- - -=============== ========================================================= -Command .. code-block:: bash - - $ tmux new-window [-n window-name] - -Short cut ``Prefix`` + ``c`` - - You may then rename window. -=============== ========================================================= - -Traverse windows ----------------- - -By number - -.. code-block:: bash - - $ tmux select-window - -Next - -.. code-block:: bash - - $ tmux next-window - -Previous - -.. code-block:: bash - - $ tmux previous-window - -Last-window - -.. code-block:: bash - - $ tmux last-window - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``n`` Change to the next window. -``p`` Change to the previous window. -``w`` Choose the current window interactively. -``0 to 9`` Select windows 0 to 9. - -``M-n`` Move to the next window with a bell or activity - marker. -``M-p`` Move to the previous window with a bell or activity - marker. - -=================== ==================================================== - -Move windows ------------- - -Move window - -.. code-block:: bash - - $ tmux move-window [-t dst-window] - -Swap the window - -.. code-block:: bash - - $ tmux swap-window [-t dst-window] - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``.`` Prompt for an index to move the current window. -=================== ==================================================== - - -Move panes ----------- - -.. code-block:: bash - - $ tmux move-pane [-t dst-pane] - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``C-o`` Rotate the panes in the current window forwards. -``{`` Swap the current pane with the previous pane. -``}`` Swap the current pane with the next pane. -=================== ==================================================== - - -Traverse panes --------------- - -Shortcut to move between panes. - -.. code-block:: bash - - $ tmux last-window - -.. code-block:: bash - - $ tmux next-window - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``Up, Down`` Change to the pane above, below, to the left, or to -``Left, Right`` the right of the current pane. -=================== ==================================================== - - -Recipe: tmux conf to ``hjkl`` commands, add this to your -``~/.tmux.conf``:: - - # hjkl pane traversal - bind h select-pane -L - bind j select-pane -D - bind k select-pane -U - bind l select-pane -R - -Kill window ------------ - -.. code-block:: bash - - $ tmux kill-window - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``&`` Kill the current window. -=================== ==================================================== - - -Kill pane ---------- - -.. code-block:: bash - - $ tmux kill-pane [-t target-pane] - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``x`` Kill the current pane. -=================== ==================================================== - -Kill window ------------ - -.. code-block:: bash - - $ tmux kill-window [-t target-window] - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``&`` Kill the current window. -=================== ==================================================== - -Splitting windows into panes ----------------------------- - -.. code-block:: bash - - $ tmux split-window [-c start-directory] - -Tmux windows can be split into multiple panes. - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``%`` Split the current pane into two, left and right. -``"`` Split the current pane into two, top and bottom. -=================== ==================================================== - -================ -Configuring Tmux -================ - -Tmux can be configured via a configuration at ``~/.tmux.conf``. - -Depending on your tmux version, there is different options available. - -Vi-style copy and paste ------------------------ - -.. code-block:: ini - - # Vi copypaste mode - set-window-option -g mode-keys vi - bind-key -t vi-copy 'v' begin-selection - bind-key -t vi-copy 'y' copy-selection - -Aggressive resizing for clients -------------------------------- - -.. code-block:: ini - - setw -g aggressive-resize on - -Reload config -------------- - -```` + ``r``. - -.. code-block:: ini - - bind r source-file ~/.tmux.conf \; display-message "Config reloaded." - -Status lines ------------- - -Tmux allows configuring a status line that displays system information, -window list, and even pipe in the ``stdout`` of an application. - -You can use `tmux-mem-cpu-load`_ to get stats (requires compilation) and -`basic-cpu-and-memory.tmux`_. You can pipe in a bash command to a tmux -status line like: - -.. code-block:: ini - - $(shell-command) - -So if ``/usr/local/bin/tmux-mem-cpu-load`` outputs stats to -``stdout``, then ``$(tmux-mem-cpu-load)`` is going to output the first -line to the status line. The interval is determined by the -``status-interval``:: - - set -g status-interval 1 - -.. _tmux-mem-cpu-load: https://github.com/thewtex/tmux-mem-cpu-load -.. _basic-cpu-and-memory.tmux: https://github.com/zaiste/tmuxified/blob/master/scripts/basic-cpu-and-memory.tmux - -Examples --------- - -- https://github.com/tony/tmux-config - works with tmux 1.5+. Supports - screen's ``ctrl-a`` :ref:`Prefix key`. Support for system cpu, memory, - uptime stats. -- Add yours, edit this page on github. - -========= -Reference -========= - -Short cuts ----------- - -.. tip:: - - :ref:`Prefix key` is pressed before a short cut! - -=================== ==================================================== -Short cut Action -------------------- ---------------------------------------------------- -``C-b`` Send the prefix key (C-b) through to the - application. -``C-o`` Rotate the panes in the current window forwards. -``C-z`` Suspend the tmux client. -``!`` Break the current pane out of the window. -``"`` Split the current pane into two, top and bottom. -``#`` List all paste buffers. -``$`` Rename the current session. -``%`` Split the current pane into two, left and right. -``&`` Kill the current window. -``'`` Prompt for a window index to select. -``,`` Rename the current window. -``-`` Delete the most recently copied buffer of text. -``.`` Prompt for an index to move the current window. -``0 to 9`` Select windows 0 to 9. -``:`` Enter the tmux command prompt. -``;`` Move to the previously active pane. -``=`` Choose which buffer to paste interactively from a - list. -``?`` List all key bindings. -``D`` Choose a client to detach. -``[`` Enter copy mode to copy text or view the history. -``]`` Paste the most recently copied buffer of text. -``c`` Create a new window. -``d`` Detach the current client. -``f`` Prompt to search for text in open windows. -``i`` Display some information about the current window. -``l`` Move to the previously selected window. -``n`` Change to the next window. -``o`` Select the next pane in the current window. -``p`` Change to the previous window. -``q`` Briefly display pane indexes. -``r`` Force redraw of the attached client. -``s`` Select a new session for the attached client - interactively. -``L`` Switch the attached client back to the last session. -``t`` Show the time. -``w`` Choose the current window interactively. -``x`` Kill the current pane. -``{`` Swap the current pane with the previous pane. -``}`` Swap the current pane with the next pane. -``~`` Show previous messages from tmux, if any. -``Page Up`` Enter copy mode and scroll one page up. -``Up, Down`` Change to the pane above, below, to the left, or to -``Left, Right`` the right of the current pane. -``M-1 to M-5`` Arrange panes in one of the five preset layouts: - even-horizontal, even-vertical, main-horizontal, - main-vertical, or tiled. -``M-n`` Move to the next window with a bell or activity - marker. -``M-o`` Rotate the panes in the current window backwards. -``M-p`` Move to the previous window with a bell or activity - marker. -``C-Up, C-Down`` Resize the current pane in steps of one cell. -``C-Left, C-Right`` -``M-Up, M-Down`` Resize the current pane in steps of five cells. -``M-Left, M-Right`` -=================== ==================================================== - -Source: tmux manpage [1]_. - -To get the text documentation of a ``.1`` manual file: - -.. code-block:: bash - - $ nroff -mdoc tmux.1|less - -.. [1] http://sourceforge.net/p/tmux/tmux-code/ci/master/tree/tmux.1 - -======= -License -======= - -This page is licensed `Creative Commons BY-NC-ND 3.0 US`_. - -.. _Creative Commons BY-NC-ND 3.0 US: http://creativecommons.org/licenses/by-nc-nd/3.0/us/ diff --git a/doc/api.rst b/doc/api.rst deleted file mode 100644 index b13db0ecdb..0000000000 --- a/doc/api.rst +++ /dev/null @@ -1,61 +0,0 @@ -.. _api: - -============= -API Reference -============= - -.. seealso:: - See :ref:`libtmux's API ` and :ref:`Quickstart - ` to see how you can control tmux via python API calls. - -.. module:: tmuxp - -Internals ---------- - -.. automethod:: tmuxp.util.run_before_script - - -Configuration -------------- - -Finding -""""""" - -.. automethod:: tmuxp.config.is_config_file -.. automethod:: tmuxp.config.in_dir -.. automethod:: tmuxp.config.in_cwd - -Import and export -""""""""""""""""" - -.. automethod:: tmuxp.config.validate_schema - -.. automethod:: tmuxp.config.expandshell - -.. automethod:: tmuxp.config.expand - -.. automethod:: tmuxp.config.inline - -.. automethod:: tmuxp.config.trickle - -.. automethod:: tmuxp.config.import_teamocil - -.. automethod:: tmuxp.config.import_tmuxinator - -Workspace Builder ------------------ - -.. autoclass:: tmuxp.WorkspaceBuilder - :members: - -Exceptions ----------- - -.. autoexception:: tmuxp.exc.EmptyConfigException - -.. autoexception:: tmuxp.exc.ConfigError - -.. autoexception:: tmuxp.exc.BeforeLoadScriptError - -.. autoexception:: tmuxp.exc.BeforeLoadScriptNotExists diff --git a/doc/cli.rst b/doc/cli.rst deleted file mode 100644 index b3349b875d..0000000000 --- a/doc/cli.rst +++ /dev/null @@ -1,114 +0,0 @@ -.. _cli: -.. _commands: - -====================== -Command Line Interface -====================== - -.. _completion: - -Completion ----------- - -In zsh (``~/.zshrc``) or bash (``~/.bashrc``): - -.. code-block:: sh - - eval "$(_TMUXP_COMPLETE=source tmuxp)" - -.. _cli_freeze: - -Freeze sessions ---------------- - -:: - - tmuxp freeze - -You can save the state of your tmux session by freezing it. - -Tmuxp will offer to save your session state to ``.json`` or ``.yaml``. - -.. _cli_load: - -Load session ------------- - -You can load your tmuxp file and attach the vim session via a few -shorthands: - -1. The directory with a ``.tmuxp.{yaml,yml,json}`` file in it -2. The name of the project file in your `$HOME/.tmuxp` folder -3. The direct path of the tmuxp file you want to load - -:: - - # path to folder with .tmuxp.{yaml,yml,json} - tmuxp load . - tmuxp load ../ - tmuxp load path/to/folder/ - tmuxp load /path/to/folder/ - - # name of the config, assume $HOME/.tmuxp/myconfig.yaml - tmuxp load myconfig - - # direct path to json/yaml file - tmuxp load ./myfile.yaml - tmuxp load /abs/path/to/myfile.yaml - tmuxp load ~/myfile.yaml - -Absolute and relative directory paths are supported. - -.. code-block:: bash - - $ tmuxp load - -Files named ``.tmuxp.yaml`` or ``.tmuxp.json`` in the current working -directory may be loaded with: - -.. code-block:: bash - - $ tmuxp load . - -Multiple sessions can be loaded at once. The first ones will be created -without being attached. The last one will be attached if there is no -``-d`` flag on the command line. - -.. code-block:: bash - - $ tmuxp load ... - -.. _cli_import: - -Import ------- - -.. _import_teamocil: - -From teamocil -~~~~~~~~~~~~~ - -:: - - tmuxp import teamocil /path/to/file.{json,yaml} - -.. _import_tmuxinator: - -From tmuxinator -~~~~~~~~~~~~~~~ - -:: - - tmuxp import tmuxinator /path/to/file.{json,yaml} - -.. _convert_config: - -Convert between YAML and JSON ------------------------------ - -:: - - tmuxp convert /path/to/file.{json,yaml} - -tmuxp automatically will prompt to convert ``.yaml`` to ``.json`` and -``.json`` to ``.yaml``. diff --git a/doc/conf.py b/doc/conf.py deleted file mode 100644 index 19e4a62050..0000000000 --- a/doc/conf.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import sys - -# Get the project root dir, which is the parent dir of this -cwd = os.getcwd() -project_root = os.path.dirname(cwd) - -sys.path.insert(0, project_root) -sys.path.append(os.path.abspath( - os.path.join(os.path.dirname(__file__), "_ext"))) - -# package data -about = {} -with open("../tmuxp/__about__.py") as fp: - exec(fp.read(), about) - - -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'aafig', - 'releases', - 'alabaster', - ] - -releases_unstable_prehistory = True -releases_document_name = "history" -releases_issue_uri = "https://github.com/tony/tmuxp/issues/%s" -releases_release_uri = "https://github.com/tony/tmuxp/tree/%s" - -templates_path = ['_templates'] - -source_suffix = '.rst' - -master_doc = 'index' - -project = about['__title__'] -copyright = about['__copyright__'] - -version = '%s' % ('.'.join(about['__version__'].split('.'))[:2]) -release = '%s' % (about['__version__']) - -exclude_patterns = ['_build'] - -pygments_style = 'sphinx' - -import alabaster - -html_theme_path = [alabaster.get_path()] -html_theme = 'alabaster' -html_sidebars = { - '**': [ - 'about.html', - 'star.html', - 'navigation.html', - 'relations.html', - 'more.html', - 'book.html', - 'searchbox.html', - ] -} - -html_theme_path = ['_themes'] -html_static_path = ['_static'] - -htmlhelp_basename = '%sdoc' % about['__title__'] - -latex_documents = [ - ('index', '{0}.tex'.format(about['__package_name__']), - '{0} Documentation'.format(about['__title__']), - about['__author__'], 'manual'), -] - -man_pages = [ - ('index', about['__package_name__'], - '{0} Documentation'.format(about['__title__']), - about['__author__'], 1), -] - -texinfo_documents = [ - ('index', '{0}'.format(about['__package_name__']), - '{0} Documentation'.format(about['__title__']), - about['__author__'], about['__package_name__'], - about['__description__'], 'Miscellaneous'), -] - -intersphinx_mapping = { - 'python': ('http://docs.python.org/', None), - 'libtmux': ('http://libtmux.readthedocs.io/en/latest', None) -} - -# aafig format, try to get working with pdf -aafig_format = dict(latex='pdf', html='gif') - -aafig_default_options = dict( - scale=.75, - aspect=0.5, - proportional=True, -) diff --git a/doc/developing.rst b/doc/developing.rst deleted file mode 100644 index cfeca9e14e..0000000000 --- a/doc/developing.rst +++ /dev/null @@ -1,200 +0,0 @@ -.. module:: tmuxp - -.. _developing: - -====================== -Developing and Testing -====================== - -.. todo:: - link to sliderepl or ipython notebook slides - -Our tests are inside ``tests/``. Tests are implemented using -`pytest`_. - -``make test`` will create a tmux server on a separate ``socket_name`` -using ``$ tmux -L test_case``. - -.. _pytest: http://pytest.org/ - -.. _install_dev_env: - -Install the latest code from git --------------------------------- - -To begin developing, check out the code from github: - -.. code-block:: bash - - $ git clone git@github.com:tony/tmuxp.git - $ cd tmuxp - -Now create a virtualenv, if you don't know how to, you can create a -virtualenv with: - -.. code-block:: bash - - $ virtualenv .venv - -Then activate it to your current tty / terminal session with: - -.. code-block:: bash - - $ source .venv/bin/activate - -Good! Now let's run this: - -.. code-block:: bash - - $ pip install -e . - -This has ``pip``, a python package manager install the python package -in the current directory. ``-e`` means ``--editable``, which means you can -adjust the code and the installed software will reflect the changes. - -.. code-block:: bash - - $ tmuxp - -Test Runner ------------ - -As you seen above, the ``tmuxp`` command will now be available to you, -since you are in the virtual environment, your `PATH` environment was -updated to include a special version of ``python`` inside your ``.venv`` -folder with its own packages. - -.. code-block:: bash - - $ make test - -You probably didn't see anything but tests scroll by. - -If you found a problem or are trying to write a test, you can file an -`issue on github`_. - -.. _test_specific_tests: - -Test runner options -~~~~~~~~~~~~~~~~~~~ - -Test only a file: - -.. code-block:: bash - - $ py.test tests/test_config.py - -will test the ``tests/test_config.py`` tests. - -.. code-block:: bash - - $ py.test tests/test_config.py::test_export_json - -tests ``test_export_json`` inside of ``tests/test_config.py``. - -Multiple can be separated by spaces: - -.. code-block:: bash - - $ py.test tests/test_{window,pane}.py tests/test_config.py::test_export_json - -.. _test_builder_visually: - -Visual testing -~~~~~~~~~~~~~~ - -You can watch tmux testsuite build sessions visually by keeping a client -open in a separate terminal. - -Create two terminals: - - - Terminal 1: ``$ tmux -L test_case`` - - Terminal 2: ``$ cd`` into the tmuxp project and into the - ``virtualenv`` if you are using one, see details on installing the dev - version of tmuxp above. Then: - - .. code-block:: bash - - $ py.test tests/test_workspacebuilder.py - -Terminal 1 should have flickered and built the session before your eyes. -tmuxp hides this building from normal users. - -Run tests on save ------------------ - -You can re-run tests automatically on file edit. - -.. note:: - This requires ``entr(1)``. - -Install `entr`_. Packages are available available on most Linux and BSD -variants, including Debian, Ubuntu, FreeBSD, OS X. - -To run all tests upon editing any ``.py`` file: - -.. code-block:: bash - - $ make watch_test - -You can also re-run a specific test file or any other `py.test usage -argument`_: - -.. code-block:: bash - - $ make watch_test test=tests/test_config.py - - $ make watch_test test='-x tests/test_config.py tests/test_util.py' - -Rebuild sphinx docs on save -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Rebuild the documentation when an ``.rst`` file is edited: - -.. code-block:: bash - - $ cd doc - $ make watch - -.. _tmuxp developer config: - -tmuxp developer config ----------------------- - -.. image:: _static/tmuxp-dev-screenshot.png - :scale: 100% - :width: 60% - :align: center - -After you :ref:`install_dev_env`, when inside the tmuxp checkout: - -.. code-block:: bash - - $ tmuxp load . - -this will load the ``.tmuxp.yaml`` in the root of the project. - -.. literalinclude:: ../.tmuxp.yaml - :language: yaml - -.. _travis: - -Travis CI -~~~~~~~~~ - -tmuxp uses `travis-ci`_ for continuous integration / automatic unit -testing. - -tmuxp is tested against tmux 1.8 and the latest git source. Interpretters -tested are 2.6, 2.7 and 3.3. The `travis build site`_ uses this -`.travis.yml`_ configuration: - -.. literalinclude:: ../.travis.yml - :language: yaml - -.. _py.test usage argument: https://pytest.org/latest/usage.html -.. _entr: http://entrproject.org/ -.. _travis-ci: http://www.travis-ci.org -.. _travis build site: http://www.travis-ci.org/tony/tmuxp -.. _.travis.yml: https://github.com/tony/tmuxp/blob/master/.travis.yml -.. _issue on github: https://github.com/tony/tmuxp/issues diff --git a/doc/examples.rst b/doc/examples.rst deleted file mode 100644 index 84f27f4d72..0000000000 --- a/doc/examples.rst +++ /dev/null @@ -1,422 +0,0 @@ -.. _examples: - -======== -Examples -======== - -Short hand / inline -------------------- - -tmuxp has a short-hand syntax to for those who wish to keep their configs -punctual. - -.. sidebar:: short hand - - .. aafig:: - :textual: - - +-------------------+ - | 'did you know' | - | 'you can inline' | - +-------------------+ - | 'single commands' | - | | - +-------------------+ - | 'for panes' | - | | - +-------------------+ - -YAML -~~~~ - -.. literalinclude:: ../examples/shorthands.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/shorthands.json - :language: json - -Blank panes ------------ - -No need to repeat ``pwd`` or a dummy command. A ``null``, ``'blank'``, -``'pane'`` are valid. - -Note ``''`` counts as an empty carriage return. - -YAML -~~~~ - -.. literalinclude:: ../examples/blank-panes.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/blank-panes.json - :language: json - -2 panes -------- - -.. sidebar:: 2 pane - - .. aafig:: - - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ - -YAML -~~~~ - -.. literalinclude:: ../examples/2-pane-vertical.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/2-pane-vertical.json - :language: json - -3 panes -------- - -.. sidebar:: 3 panes - - .. aafig:: - - +-----------------+ - | $ pwd | - | | - | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ - -YAML -~~~~ - -.. literalinclude:: ../examples/3-pane.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/3-pane.json - :language: json - -4 panes -------- - -.. sidebar:: 4 panes - - .. aafig:: - - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ - -YAML -~~~~ - -.. literalinclude:: ../examples/4-pane.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/4-pane.json - :language: json - -Start Directory ---------------- - -Equivalent to ``tmux new-window -c ``. - -YAML -~~~~ - -.. literalinclude:: ../examples/start-directory.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/start-directory.json - :language: json - -Environment variable replacing ------------------------------- - -tmuxp will replace environment variables wrapped in curly brackets -for values of these settings: - -- ``start_directory`` -- ``before_script`` -- ``session_name`` -- ``window_name`` -- ``shell_command_before`` -- ``global_options`` -- ``options`` in session scope and window scope - -tmuxp replaces these variables before-hand with variables in the -terminal ``tmuxp`` invokes in. - -In this case of this example, assuming the username "user":: - - $ MY_ENV_VAR=foo tmuxp load examples/env-variables.yaml - -and your session name will be ``session - user (foo)``. - -Shell variables in ``shell_command`` do not support this type of -concatenation. ``shell_command`` and ``shell_command_before`` both -support normal shell variables, since they are sent into panes -automatically via ``send-key`` in ``tmux(1)``. See ``ls $PWD`` in -example. - -If you have a special case and would like to see behavior changed, -please make a ticket on the `issue tracker`_. - -.. _issue tracker: https://github.com/tony/tmuxp/issues - -YAML -~~~~ - -.. literalinclude:: ../examples/env-variables.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/env-variables.json - :language: json - -Environment variables ---------------------- - -tmuxp will set session environment variables. - -YAML -~~~~ - -.. literalinclude:: ../examples/session-environment.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/session-environment.json - :language: json - -Focusing --------- - -tmuxp allows ``focus: true`` for assuring windows and panes are attached / -selected upon loading. - -YAML -~~~~ - -.. literalinclude:: ../examples/focus-window-and-panes.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/focus-window-and-panes.json - :language: json - -Terminal History ----------------- - -tmuxp allows ``suppress_history: false`` to override the default command / -suppression when building the workspace. -This will add the ``shell_command`` to the bash history in the pane. - -YAML -~~~~ - -.. literalinclude:: ../examples/suppress-history.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/suppress-history.json - :language: json - -Window Index ------------- - -You can specify a window's index using the ``window_index`` property. Windows -without ``window_index`` will use the lowest available window index. - -YAML -~~~~ - -.. literalinclude:: ../examples/window-index.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/window-index.json - :language: json - -Set tmux options ----------------- - -Works with global (server-wide) options, session options -and window options. - -Including ``automatic-rename``, ``default-shell``, -``default-command``, etc. - -YAML -~~~~ - -.. literalinclude:: ../examples/options.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/options.json - :language: json - -Main pane height ----------------- - -YAML -~~~~ - -.. literalinclude:: ../examples/main-pane-height.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../examples/main-pane-height.json - :language: json - -Super-advanced dev environment ------------------------------- - -.. seealso:: - :ref:`tmuxp developer config` in the :ref:`developing` section. - -YAML -~~~~ - -.. literalinclude:: ../.tmuxp.yaml - :language: yaml - -JSON -~~~~ - -.. literalinclude:: ../.tmuxp.json - :language: json - -Bootstrap project before launch -------------------------------- - -You can use ``before_script`` to run a script before the tmux session -starts building. This can be used to start a script to create a virtualenv -or download a virtualenv/rbenv/package.json's dependency files before -tmuxp even begins building the session. - -It works by using the `Exit Status`_ code returned by a script. Your -script can be any type, including bash, python, ruby, etc. - -A successful script will exit with a status of ``0``. - -Important: the script file must be chmod executable ``+x`` or ``755``. - -Run a python script (and check for it's return code), the script is -*relative to the ``.tmuxp.yaml``'s root* (Windows and panes omitted in -this example): - -.. code-block:: yaml - - session_name: my session - before_script: ./bootstrap.py - # ... the rest of your config - -.. code-block:: json - - { - "session_name": "my session", - "before_script": "./bootstrap.py" - } - -Run a shell script + check for return code on an absolute path. (Windows -and panes omitted in this example) - -.. code-block:: yaml - - session_name: another example - before_script: /absolute/path/this.sh # abs path to shell script - # ... the rest of your config - -.. code-block:: json - - { - "session_name": "my session", - "before_script": "/absolute/path/this.sh" - } - -.. _Exit Status: http://tldp.org/LDP/abs/html/exit-status.html - -Per-project tmux config ------------------------ - -You can load your software project in tmux by placing a ``.tmuxp.yaml`` or -``.tmuxp.json`` in the project's config and loading it. - -tmuxp supports loading configs via absolute filename with ``tmuxp load`` -and via ``$ tmuxp load .`` if config is in directory. - -.. code-block:: bash - - $ tmuxp load ~/workspaces/myproject.yaml - -See examples of ``tmuxp`` in the wild. Have a project config to show off? -Edit this page. - -* https://github.com/tony/dockerfiles/blob/master/.tmuxp.yaml -* https://github.com/tony/pullv/blob/master/.tmuxp.yaml -* https://github.com/tony/sphinxcontrib-github/blob/master/.tmuxp.yaml - -You can use ``start_directory: ./`` to make the directories relative to -the config file / project root. - -Kung fu -------- - -.. note:: - - tmuxp sessions can be scripted in python. The first way is to use the - ORM in the :ref:`API`. The second is to pass a :py:obj:`dict` into - :class:`tmuxp.WorkspaceBuilder` with a correct schema. See: - :meth:`tmuxp.config.validate_schema`. - -Add yours? Submit a pull request to the `github`_ site! - -.. _github: https://github.com/tony/tmuxp diff --git a/doc/glossary.rst b/doc/glossary.rst deleted file mode 100644 index dcdea84f76..0000000000 --- a/doc/glossary.rst +++ /dev/null @@ -1,67 +0,0 @@ -.. _glossary: - -======== -Glossary -======== - - -.. glossary:: - - tmuxp - A tool to manage workspaces with tmux. A pythonic abstraction of - tmux. - - tmux(1) - The tmux binary. Used internally to distinguish tmuxp is only a - layer on top of tmux. - - kaptan - configuration management library, see `kaptan on github`_. - - Server - Tmux runs in the background of your system as a process. - - The server holds multiple :term:`Session`. By default, tmux - automatically starts the server the first time ``$ tmux`` is ran. - - A server contains :term:`session`'s. - - tmux starts the server automatically if it's not running. - - Advanced cases: multiple can be run by specifying - ``[-L socket-name]`` and ``[-S socket-path]``. - - Client - Attaches to a tmux :term:`server`. When you use tmux through CLI, - you are using tmux as a client. - - Session - Inside a tmux :term:`server`. - - The session has 1 or more :term:`Window`. The bottom bar in tmux - show a list of windows. Normally they can be navigated with - ``Ctrl-a [0-9]``, ``Ctrl-a n`` and ``Ctrl-a p``. - - Sessions can have a ``session_name``. - - Uniquely identified by ``session_id``. - - Window - Entity of a :term:`session`. - - Can have 1 or more :term:`pane`. - - Panes can be organized with a layouts. - - Windows can have names. - - Pane - Linked to a :term:`Window`. - - a pseudoterminal. - - Target - A target, cited in the manual as ``[-t target]`` can be a session, - window or pane. - -.. _kaptan on github: https://github.com/emre/kaptan diff --git a/doc/history.rst b/doc/history.rst deleted file mode 100644 index 74672882b1..0000000000 --- a/doc/history.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _history: - -======= -History -======= - -.. module:: tmuxp - -.. include:: ../CHANGES - :start-line: 4 diff --git a/doc/index.rst b/doc/index.rst deleted file mode 100644 index b9634f57f2..0000000000 --- a/doc/index.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _index: - -##### -tmuxp -##### -==================== -tmux session manager -==================== - -.. include:: ../README.rst - :start-line: 4 - :end-before: .. image - -.. image:: _static/tmuxp-demo.gif - :width: 100% - - -Freeze a tmux session ---------------------- -.. include:: ../README.rst - :start-after: Freeze a tmux session - -Explore: - -.. toctree:: - :maxdepth: 2 - - about - quickstart - examples - cli - developing - api - history - about_tmux - glossary diff --git a/doc/quickstart.rst b/doc/quickstart.rst deleted file mode 100644 index 3722506ab5..0000000000 --- a/doc/quickstart.rst +++ /dev/null @@ -1,96 +0,0 @@ -.. _quickstart: - -========== -Quickstart -========== - -Installation ------------- - -Assure you have at least tmux **>= 1.8** and python **>= 2.6**. For Ubuntu 12.04/12.10/13.04 users, you can download the tmux 1.8 package for Ubuntu 13.10 from `https://launchpad.net/ubuntu/+source/tmux `_ and install it using dpkg. - -.. code-block:: bash - - $ pip install tmuxp - -You can upgrade to the latest release with: - -.. code-block:: bash - - $ pip install tmuxp -U - -Then install :ref:`completion`. - -CLI ---- - -.. seealso:: :ref:`examples`, :ref:`cli`, :ref:`completion`. - -tmuxp launches workspaces / sessions from JSON and YAML files. - -Configuration files can be stored in ``$HOME/.tmuxp`` or in project -directories as ``.tmuxp.py``, ``.tmuxp.json`` or ``.tmuxp.yaml``. - -Every configuration is required to have: - -1. ``session_name`` -2. list of ``windows`` -3. list of ``panes`` for every window in ``windows`` - -Create a file, ``~/.tmuxp/example.yaml``: - -.. literalinclude:: ../examples/2-pane-vertical.yaml - :language: yaml - -.. code-block:: bash - - $ tmuxp load example.yaml - -This creates your tmuxp session. - -Load multiple tmux sessions at once: - -.. code-block:: bash - - $ tmuxp load example.yaml anothersession.yaml - -tmuxp will offer to ``switch-client`` for you if you're already in a -session. - -You can also `Import`_ configs `teamocil`_ and `tmuxinator`_. - -.. _Import: http://tmuxp.readthedocs.io/en/latest/cli.html#import -.. _tmuxinator: https://github.com/aziz/tmuxinator -.. _teamocil: https://github.com/remiprev/teamocil - - - -Pythonics ---------- - -.. seealso:: - :ref:`libtmux python API documentation ` and :ref:`developing`, - :ref:`internals`. - - -ORM - `Object Relational Mapper`_ - -AL - `Abstraction Layer`_ - -.. _Abstraction Layer: http://en.wikipedia.org/wiki/Abstraction_layer -.. _Object Relational Mapper: http://en.wikipedia.org/wiki/Object-relational_mapping - -python abstraction layer -"""""""""""""""""""""""" - -======================================== ================================= -:ref:`tmuxp python api ` :term:`tmux(1)` equivalent -======================================== ================================= -:meth:`libtmux.Server.new_session` ``$ tmux new-session`` -:meth:`libtmux.Server.list_sessions` ``$ tmux list-sessions`` -:meth:`libtmux.Session.list_windows` ``$ tmux list-windows`` -:meth:`libtmux.Session.new_window` ``$ tmux new-window`` -:meth:`libtmux.Window.list_panes` ``$ tmux list-panes`` -:meth:`libtmux.Window.split_window` ``$ tmux split-window`` -:meth:`libtmux.Pane.send_keys` ``$ tmux send-keys`` -======================================== ================================= diff --git a/doc/requirements.txt b/doc/requirements.txt deleted file mode 100644 index 04b4dec7d9..0000000000 --- a/doc/requirements.txt +++ /dev/null @@ -1 +0,0 @@ --r ../requirements/doc.txt diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000..da88da5f44 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,119 @@ +# Documentation voice + +This file covers the *voice* of prose under `docs/` — how to frame a +feature page so a reader meets the idea before its configuration. It +complements the repository-root `AGENTS.md`, which already governs code +blocks, shell-command formatting, changelog conventions, and MyST +roles. When the two overlap, the root file wins; this one only answers +the question it leaves open: how should the prose sound? + +## Who you are writing for + +The default reader runs tmuxp and writes workspace files in YAML or +JSON. They are fluent in tmux itself — servers, sessions, windows, +panes, layouts, the shell and its prompt — but you cannot assume they +read Python, know tmuxp's internals, or have heard of its builder +architecture, entry points, or `sys.path`. + +A second, smaller reader writes Python: custom builders, plugins, code +against libtmux. Serve them too, but mark their material as opt-in +("for the braver cases", "advanced") so the default reader knows they +can stop. Never make the common case pay a comprehension tax for the +advanced one. + +## Voice + +- **Second person, present tense, active.** "You name the builder", not + "The builder is selected". Address the reader who is doing the thing. +- **Concept before configuration.** Open by saying what the thing *is* + and what it does for the reader. The YAML surface — the keys, the + flags — is the last detail they need, not the first. A page that + opens with "set these keys" has buried the idea under its mechanics. +- **Say when they can stop.** Lead with the default and the + reassurance: most readers never touch this, it works out of the box, + everything here is optional. Let a skimmer leave after one sentence. +- **Progressive disclosure.** Order by how many readers need it: + default → the one option a few will tune → swapping the whole thing + → writing your own. Each step is for a smaller audience than the last. +- **Name the trade-off.** If an option costs something — load time, a + slower attach — say so, and say what it buys ("a little slower, but + the workspace is fully prepped before you attach"). State it; don't + sell it. +- **Frame by concept, not by mechanism.** Don't call a feature "the + keys" or "the flags" in prose; that names the implementation surface, + which is the reader's last concern. Name the concept. The mechanics + vocabulary — a `Key` / `Type` / `Default` table — is correct in a + reference table, and only there. + +## What stays precise + +Warm the framing, never the facts. Resolution-order lists, value +tables, exact error strings, and class or function cross-references +carry meaning in their exact form — leave them alone. The friendly +voice belongs in the sentences *around* a precise block, introducing +it, not inside it paraphrasing it into vagueness. + +## Cross-references + +Point the advanced reader at the deep-dive rather than inlining it, and +put the link where their interest peaks — on the phrase that made them +curious ("write your own") — not as a standalone footnote the eye +skips. Use the MyST roles listed in the root `AGENTS.md`. + +Link the first prose mention of any symbol that has a useful destination on +that page. This includes Python objects, tmuxp APIs, libtmux APIs, CLI command +pages, topic/configuration pages, and external tools or projects. Use the most +specific target available: `{class}`, `{meth}`, `{func}`, `{mod}`, `{exc}`, or +`{attr}` for API objects; `{ref}` or `{doc}` for documentation pages and +section anchors; and a Markdown link or reference link for external projects. +After the first linked mention on a page, later mentions can stay plain unless +the distance or context makes another link useful. + +Do not rely on a later reference section to satisfy the first-mention rule. If +the first occurrence would be a heading, grid-card teaser, or introductory +sentence, link that occurrence or retitle the heading so the first prose mention +can carry the link. Leave command examples, code blocks, Mermaid node labels, +and literal configuration values as code; link the surrounding prose instead. + +## A page that does this + +`docs/configuration/workspace-builders.md` is the worked example: +a concept-first intro, an out-of-the-box reassurance, sections ordered +by shrinking audience, an honest trade-off on the prompt wait, and +precise reference tables left precise. Read it before reshaping another +page. + +## Diagrams and reference pages + +Two mechanical conventions, separate from voice: + +- **Mermaid diagrams** render to inline SVG at build time (via the + `sphinx-gp-mermaid` package). Tag any node whose label is a command, + code identifier, config key, or other symbol with `:::cmd` so it + renders monospace — + the way that text reads as code inline; leave prose and concept nodes + unstyled. Prefer top-to-bottom (`flowchart TD`); wide left-to-right + charts don't scale on narrow viewports. Add `:alt:`, `:name:`, and + `:responsive: fit` to every diagram; use `:responsive: preserve` only + when the wide artifact is intentional and should scroll instead of + shrinking. `docs/configuration/workspace-builders.md` is the reference. +- **Internal API pages** document a module with an `{eval-rst}` block + wrapping `.. automodule:: ` (with `:members:`), the way the + existing `docs/internals/api/**` pages do. A bare `.. py:module::` + registers a cross-reference target but renders an empty page — reach + for it only to add a *package* target to an index page that already + carries its own content (grids, prose), where `automodule` would + duplicate members documented on the leaf pages. + +## Before you commit + +- Does the page open with what the feature *is*, or with how to + configure it? +- Can a reader who needs only the default stop after the first + paragraph? +- Is anything framed as "the keys/flags" that should be named by + concept instead? +- Are the advanced and Python-only parts clearly marked opt-in? +- Did you leave every table, error string, and cross-reference exact? +- Are diagram command/symbol nodes tagged `:::cmd`, and is the chart + vertical unless it has a reason to be wide? diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/doc/_ext/__init__.py b/docs/_ext/__init__.py similarity index 100% rename from doc/_ext/__init__.py rename to docs/_ext/__init__.py diff --git a/docs/_ext/aafig.py b/docs/_ext/aafig.py new file mode 100644 index 0000000000..6f90d51378 --- /dev/null +++ b/docs/_ext/aafig.py @@ -0,0 +1,241 @@ +"""aafig plugin for sphinx. + +sphinxcontrib.aafig +~~~~~~~~~~~~~~~~~~~ + +Allow embedded ASCII art to be rendered as nice looking images +using the aafigure reStructuredText extension. + +See the README file for details. + +:author: Leandro Lucarella +:license: BOLA, see LICENSE for details +""" + +from __future__ import annotations + +import locale +import logging +import pathlib +import posixpath +import typing as t +from hashlib import sha1 as sha +from os import path + +from docutils import nodes +from docutils.parsers.rst.directives import flag, images, nonnegative_int +from sphinx.errors import SphinxError +from sphinx.util.osutil import ensuredir, relative_uri + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + + +try: + import aafigure +except ImportError: + aafigure = None + + +logger = logging.getLogger(__name__) + +DEFAULT_FORMATS = {"html": "svg", "latex": "pdf", "text": None} + + +def merge_dict( + dst: dict[str, str | None], + src: dict[str, str | None], +) -> dict[str, str | None]: + for k, v in src.items(): + if k not in dst: + dst[k] = v + return dst + + +def get_basename( + text: str, + options: dict[str, str], + prefix: str | None = "aafig", +) -> str: + options = options.copy() + if "format" in options: + del options["format"] + hashkey = text + str(options) + id_ = sha(hashkey.encode("utf-8")).hexdigest() + return f"{prefix}-{id_}" + + +class AafigError(SphinxError): + category = "aafig error" + + +class AafigDirective(images.Image): + """Directive to insert an ASCII art figure to be rendered by aafigure.""" + + has_content = True + required_arguments = 0 + own_option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "line_width": float, + "background": str, + "foreground": str, + "fill": str, + "aspect": nonnegative_int, + "textual": flag, + "proportional": flag, + } + option_spec = ( + images.Image.option_spec.copy() if images.Image.option_spec is not None else {} + ) + option_spec.update(own_option_spec) + + def run(self) -> list[nodes.Node]: + aafig_options = {} + own_options_keys = [self.own_option_spec.keys(), "scale"] + for k, v in self.options.items(): + if k in own_options_keys: + # convert flags to booleans + if v is None: + v = True + # convert percentage to float + if k in {"scale", "aspect"}: + v = float(v) / 100.0 + aafig_options[k] = v + del self.options[k] + self.arguments = [""] + (image_node,) = images.Image.run(self) + if isinstance(image_node, nodes.system_message): + return [image_node] + text = "\n".join(self.content) + image_node.aafig = {"options": aafig_options, "text": text} # type: ignore[attr-defined] + return [image_node] + + +def render_aafig_images(app: Sphinx, doctree: nodes.Node) -> None: + format_map = app.builder.config.aafig_format + merge_dict(format_map, DEFAULT_FORMATS) + if aafigure is None: + logger.warning( + "aafigure module not installed, ASCII art images " + "will be rendered as literal text", + ) + for img in doctree.traverse(nodes.image): + if not hasattr(img, "aafig"): + continue + if aafigure is None: + continue + options = img.aafig["options"] + text = img.aafig["text"] + format_ = app.builder.format + merge_dict(options, app.builder.config.aafig_default_options) + if format_ in format_map: + options["format"] = format_map[format_] + else: + logger.warning( + ( + 'unsupported builder format "%s", please add a custom entry in ' + "aafig_format config option for this builder" + ), + format_, + ) + img.replace_self(nodes.literal_block(text, text)) + continue + if options["format"] is None: + img.replace_self(nodes.literal_block(text, text)) + continue + try: + fname, _outfn, _id, extra = render_aafigure(app, text, options) + except AafigError as exc: + logger.warning("aafigure error: " + str(exc)) + img.replace_self(nodes.literal_block(text, text)) + continue + img["uri"] = fname + # FIXME: find some way to avoid this hack in aafigure + if extra: + (width, height) = (x.split('"')[1] for x in extra.split()) + if "width" not in img: + img["width"] = width + if "height" not in img: + img["height"] = height + + +class AafigureNotInstalled(AafigError): + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__("aafigure module not installed", *args, **kwargs) + + +def render_aafigure( + app: Sphinx, + text: str, + options: dict[str, str], +) -> tuple[str, str, str | None, str | None]: + """Render an ASCII art figure into the requested format output file.""" + if aafigure is None: + raise AafigureNotInstalled + + fname = get_basename(text, options) + fname = "{}.{}".format(get_basename(text, options), options["format"]) + if app.builder.format == "html": + # HTML + target_uri = app.builder.get_target_uri(app.builder.env.docname) + imgpath = relative_uri(target_uri, "_images") + relfn = posixpath.join(imgpath, fname) + outfn = path.join(app.builder.outdir, "_images", fname) + else: + # Non-HTML + if app.builder.format != "latex": + logger.warning( + f"aafig: the builder format {app.builder.format} is not officially " + "supported, aafigure images could not work. " + "Please report problems and working builder to " + "avoid this warning in the future", + ) + relfn = fname + outfn = path.join(app.builder.outdir, fname) + metadata_fname = f"{outfn}.aafig" + + try: + if path.isfile(outfn): + extra = None + if options["format"].lower() == "svg": + f = None + try: + try: + extra = pathlib.Path(metadata_fname).read_text( + encoding=locale.getpreferredencoding(False), + ) + except Exception as e: + raise AafigError from e + finally: + if f is not None: + f.close() + return relfn, outfn, None, extra + except AafigError: + pass + + ensuredir(path.dirname(outfn)) + + try: + (visitor, output) = aafigure.render(text, outfn, options) + output.close() + except aafigure.UnsupportedFormatError as e: + raise AafigError(str(e)) from e + + extra = None + if options["format"].lower() == "svg": + extra = visitor.get_size_attrs() + pathlib.Path(metadata_fname).write_text( + extra, + encoding=locale.getpreferredencoding(False), + ) + + return relfn, outfn, None, extra + + +def setup(app: Sphinx) -> None: + app.add_directive("aafig", AafigDirective) + app.connect("doctree-read", render_aafig_images) + app.add_config_value("aafig_format", DEFAULT_FORMATS, "html") + app.add_config_value("aafig_default_options", {}, "html") + + +# vim: set expandtab shiftwidth=4 softtabstop=4 : diff --git a/docs/_ext/conftest.py b/docs/_ext/conftest.py new file mode 100644 index 0000000000..d02630946d --- /dev/null +++ b/docs/_ext/conftest.py @@ -0,0 +1,15 @@ +"""Pytest configuration for docs/_ext doctests. + +This module sets up sys.path so that sphinx_argparse_neo and other extension +modules can be imported correctly during pytest doctest collection. +""" + +from __future__ import annotations + +import pathlib +import sys + +# Add docs/_ext to sys.path so sphinx_argparse_neo can import itself +_ext_dir = pathlib.Path(__file__).parent +if str(_ext_dir) not in sys.path: + sys.path.insert(0, str(_ext_dir)) diff --git a/docs/_ext/tmux_layout.py b/docs/_ext/tmux_layout.py new file mode 100644 index 0000000000..1f74fc181a --- /dev/null +++ b/docs/_ext/tmux_layout.py @@ -0,0 +1,393 @@ +"""Render tmux window layouts as inline SVG that looks like a terminal. + +A ``tmux-layout`` directive declares a screen size and a tmux layout; the panes +tile the screen with no gaps, exactly the way tmux splits a window. Each pane's +shell commands are drawn top-left in a fixed-width font and syntax-highlighted +with Pygments, so the diagram reads like the real terminal. This is the +spiritual successor to the old ``aafig`` ASCII-art boxes, but tmux-aware. + +Authoring — panes are separated by a ``---`` line, each holding that pane's +commands:: + + :::{tmux-layout} + :size: 64x18 + :layout: even-vertical + + echo 'did you know' + echo 'you can inline' + --- + echo 'single commands' + --- + echo 'for panes' + ::: + +``:layout:`` is one of tmux's arrangements: ``even-vertical`` (default), +``even-horizontal``, ``main-vertical``, ``main-horizontal``, ``tiled``. + +Output is a single inline ```` whose colours are CSS custom properties +(``gp-tmux-layout.css``), so it paints with the page, adapts to light/dark via +``body[data-theme]`` with no JavaScript and no second render, and rides +gp-sphinx SPA swaps as live DOM — without the headless-browser dependency the +mermaid pipeline needs. +""" + +from __future__ import annotations + +import html +import itertools +import typing as t + +from docutils import nodes +from pygments.lexers.shell import BashLexer +from pygments.token import STANDARD_TYPES +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.writers.html5 import HTML5Translator + +logger = logging.getLogger(__name__) + +#: Pixels per tmux character cell (column width, row height). The viewBox is in +#: these pixels, so at the SVG's intrinsic size one unit is one CSS pixel and +#: the text renders at the page's code font size, like a real code block. +_CW = 8.0 +_CH = 17.0 +#: Padding (px) between a pane edge and its text. +_PAD = 6.0 +#: Approx. font ascent (px) for placing the first baseline below the padding. +_FONT = 12.0 +#: Baseline-to-baseline distance (px) for stacked command lines. +_LINE_H = 17.0 +#: A simple shell prompt prefixed to each command line. The ``gp`` class is +#: Pygments' Generic.Prompt, so it takes the theme's prompt colour (like the +#: ``$`` in the site's console blocks). +_PROMPT = '' # noqa: RUF001 + +_LAYOUTS = ( + "even-vertical", + "even-horizontal", + "main-vertical", + "main-horizontal", + "tiled", +) +_LEXER = BashLexer() +_SVG_IDS = itertools.count(1) + + +class TmuxLayoutError(ValueError): + """A tmux-layout directive could not be rendered.""" + + +class Rect(t.NamedTuple): + """A pane rectangle in character-cell coordinates.""" + + x: float + y: float + w: float + h: float + + +def _even(n: int, w: float, h: float, *, vertical: bool) -> list[Rect]: + """Split the screen into ``n`` equal panes, filling it completely. + + >>> [round(r.h, 2) for r in _even(2, 80, 24, vertical=True)] + [12.0, 12.0] + >>> [round(r.w, 2) for r in _even(2, 80, 24, vertical=False)] + [40.0, 40.0] + """ + rects = [] + for i in range(n): + if vertical: + y0, y1 = i * h / n, (i + 1) * h / n + rects.append(Rect(0.0, y0, w, y1 - y0)) + else: + x0, x1 = i * w / n, (i + 1) * w / n + rects.append(Rect(x0, 0.0, x1 - x0, h)) + return rects + + +def _main( + n: int, w: float, h: float, *, vertical: bool, ratio: float = 0.5 +) -> list[Rect]: + """One main pane plus the rest split evenly beside/below it. + + >>> r = _main(3, 80, 24, vertical=True) + >>> (round(r[0].w), round(r[1].x), len(r)) + (40, 40, 3) + """ + if n == 1: + return [Rect(0.0, 0.0, w, h)] + if vertical: + mw = w * ratio + rects = [Rect(0.0, 0.0, mw, h)] + rects += [ + Rect(mw + r.x, r.y, r.w, r.h) + for r in _even(n - 1, w - mw, h, vertical=True) + ] + else: + mh = h * ratio + rects = [Rect(0.0, 0.0, w, mh)] + rects += [ + Rect(r.x, mh + r.y, r.w, r.h) + for r in _even(n - 1, w, h - mh, vertical=False) + ] + return rects + + +def _tiled(n: int, w: float, h: float) -> list[Rect]: + """Tile ``n`` panes into a grid that fills the screen (tmux ``tiled``). + + >>> len(_tiled(4, 80, 24)), round(_tiled(4, 80, 24)[0].w) + (4, 40) + """ + rows = cols = 1 + while rows * cols < n: + rows += 1 + if rows * cols < n: + cols += 1 + rects = [] + for i in range(n): + r, c = divmod(i, cols) + in_row = cols if r < rows - 1 else n - cols * (rows - 1) + x0, x1 = c * w / in_row, (c + 1) * w / in_row + y0, y1 = r * h / rows, (r + 1) * h / rows + rects.append(Rect(x0, y0, x1 - x0, y1 - y0)) + return rects + + +def arrange(layout: str, n: int, w: int, h: int) -> list[Rect]: + """Return ``n`` pane rectangles filling a ``w`` x ``h`` screen. + + >>> [round(r.h) for r in arrange("even-vertical", 3, 80, 24)] + [8, 8, 8] + >>> len(arrange("tiled", 4, 80, 24)) + 4 + """ + if n < 1: + msg = "a tmux layout needs at least one pane" + raise TmuxLayoutError(msg) + if n == 1: + return [Rect(0.0, 0.0, float(w), float(h))] + if layout == "even-vertical": + return _even(n, w, h, vertical=True) + if layout == "even-horizontal": + return _even(n, w, h, vertical=False) + if layout == "main-vertical": + return _main(n, w, h, vertical=True) + if layout == "main-horizontal": + return _main(n, w, h, vertical=False) + if layout == "tiled": + return _tiled(n, w, h) + msg = f"unknown layout {layout!r}; expected one of {', '.join(_LAYOUTS)}" + raise TmuxLayoutError(msg) + + +def _highlight(line: str) -> str: + """Return a shell command as Pygments-classed ````s. + + The command name (a shell builtin like ``echo``/``pwd``) is left in the + default text colour, like a freshly typed command; strings, comments, and + the like keep their Pygments classes. + + >>> 'class="nb"' in _highlight("echo hello") + False + >>> 'class="s1"' in _highlight("echo 'hi'") + True + >>> "'hi'" in _highlight("echo 'hi'") + True + """ + spans = [] + for token, value in _LEXER.get_tokens(line): + text = value.rstrip("\n") + if not text: + continue + css = STANDARD_TYPES.get(token, "") + if css == "nb": # command builtins (echo, pwd) keep the default colour + css = "" + escaped = html.escape(text) + spans.append(f'{escaped}' if css else escaped) + return "".join(spans) + + +def _clip_id(prefix: str, index: int) -> str: + """Return a clip-path id for one pane in a rendered SVG. + + >>> _clip_id("tmux-layout-1", 0) + 'tmux-layout-1-clip-0' + """ + return f"{prefix}-clip-{index}" + + +def _parse_size(size: str) -> tuple[int, int]: + """Parse a ``WxH`` screen size. + + >>> _parse_size("64x18") + (64, 18) + """ + try: + w_str, h_str = size.lower().split("x", 1) + return int(w_str), int(h_str) + except ValueError as exc: + msg = f"invalid size {size!r}; expected WxH (e.g. 80x24)" + raise TmuxLayoutError(msg) from exc + + +def render_layout(panes: list[list[str]], layout: str, size: tuple[int, int]) -> str: + """Render panes (each a list of command lines) to an inline ````. + + >>> svg = render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + >>> svg.startswith("', + ] + parts.append("") + for index, rect in enumerate(rects): + clip_x = rect.x * _CW + 1 + clip_y = rect.y * _CH + 1 + clip_w = max(rect.w * _CW - 2, 1.0) + clip_h = max(rect.h * _CH - 2, 1.0) + parts.append( + f'' + f'', + ) + parts.append("") + # Pane fills first; the window border and single dividers go on top so a + # shared edge is one line, not two abutting pane strokes. + parts.extend( + f'' + for rect in rects + ) + for rect in rects: + right, bottom = rect.x + rect.w, rect.y + rect.h + if right < w: # internal vertical divider + parts.append( + f'', + ) + if bottom < h: # internal horizontal divider + parts.append( + f'', + ) + parts.append( + f'', + ) + # Pygments colours come from furo's own `.highlight` rules (theme-matched, + # light/dark) via `fill: currentColor`; a ignores its background. + parts.append('') + for index, (rect, commands) in enumerate(zip(rects, panes, strict=True)): + tx = rect.x * _CW + _PAD + baseline = rect.y * _CH + _PAD + _FONT + clip_path = _clip_id(svg_id, index) + for i, line in enumerate(commands): + body = _highlight(line) + if not body: + continue + ty = baseline + i * _LINE_H + parts.append( + f'{_PROMPT}{body}', + ) + parts.append("") + return "".join(parts) + + +def _split_panes(content: list[str]) -> list[list[str]]: + """Split directive content on ``---`` lines into per-pane command lists. + + >>> _split_panes(["echo a", "echo b", "---", "echo c"]) + [['echo a', 'echo b'], ['echo c']] + """ + panes: list[list[str]] = [[]] + for line in content: + if line.strip() == "---": + panes.append([]) + else: + panes[-1].append(line.rstrip()) + return [ + [line for line in pane if line.strip()] + for pane in panes + if any(p.strip() for p in pane) + ] + + +class tmux_layout(nodes.General, nodes.Element): + """Doctree node carrying a rendered tmux-layout SVG.""" + + +class TmuxLayoutDirective(SphinxDirective): + """Render a declared tmux layout as an inline, terminal-styled SVG.""" + + has_content = True + required_arguments = 0 + optional_arguments = 0 + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "size": lambda x: x, + "layout": lambda x: x, + "caption": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Return a :class:`tmux_layout` node with the rendered SVG.""" + panes = _split_panes(list(self.content)) + if not panes: + warning = self.state.document.reporter.warning( + "tmux-layout directive has no panes", + line=self.lineno, + ) + return [warning] + layout = self.options.get("layout", "even-vertical") + try: + svg = render_layout( + panes, layout, _parse_size(self.options.get("size", "80x24")) + ) + except TmuxLayoutError as exc: + logger.warning("invalid tmux layout: %s", exc, location=self.get_location()) + return [ + nodes.literal_block("\n".join(self.content), "\n".join(self.content)) + ] + node = tmux_layout() + node["svg"] = svg + node["caption"] = self.options.get("caption", "") + return [node] + + +def html_visit_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None: + """Append the inline SVG (wrapped in a figure) and skip the node.""" + caption = node.get("caption", "") + parts = [f'
{node["svg"]}'] + if caption: + parts.append(f"
{html.escape(caption)}
") + parts.append("
") + self.body.append("".join(parts)) + raise nodes.SkipNode + + +def _depart_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None: + """No-op; :func:`html_visit_tmux_layout` raises ``SkipNode``.""" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive, node, and stylesheet.""" + app.add_node(tmux_layout, html=(html_visit_tmux_layout, _depart_tmux_layout)) + app.add_directive("tmux-layout", TmuxLayoutDirective) + app.add_css_file("css/gp-tmux-layout.css") + return { + "version": "0.1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/gp-tmux-layout.css b/docs/_static/css/gp-tmux-layout.css new file mode 100644 index 0000000000..8ed400bfeb --- /dev/null +++ b/docs/_static/css/gp-tmux-layout.css @@ -0,0 +1,73 @@ +/* + * tmux window layouts (docs/_ext/tmux_layout.py). + * + * Plain inline SVG: it inherits furo's CSS custom properties and adapts to + * light/dark via body[data-theme] with no JavaScript and no second render. + * + * Shell highlighting reuses furo's own Pygments rules: the command text is + * wrapped in , so `.highlight .nb`, `.highlight .gp` + * (the prompt), etc. apply, and SVG takes its colour from `color` via + * `fill: currentColor` — so the panes match the site's console blocks exactly, + * colours in both light and dark. The SVG itself owns the command text size + * because compact tiled panes need predictable cell math. + */ + +.gp-tmux-layout-wrapper { + margin: 1.5rem 0; + text-align: center; +} + +.gp-tmux-layout { + max-width: min(100%, 480px); + height: auto; + display: block; + margin: 0 auto; +} + +/* + * Pane background mirrors the Pygments code-block background, with the same + * light/dark switching furo uses for `.highlight` (pygments_style and + * pygments_dark_style backgrounds). + */ +.gp-tmux-layout .pane { + fill: #f8fafc; +} + +body[data-theme="dark"] .gp-tmux-layout .pane { + fill: #272822; +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) .gp-tmux-layout .pane { + fill: #272822; + } +} + +/* Single-stroke window border and pane dividers. */ +.gp-tmux-layout .window { + fill: none; + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout .divider { + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout text { + font-family: var(--font-stack--monospace, "SFMono-Regular", Menlo, monospace); + font-size: 12px; + /* Match the site's code blocks (pre): even monospace advance, no kerning. */ + font-kerning: none; + text-rendering: optimizeSpeed; + letter-spacing: normal; + /* Colour comes from furo's .highlight / .highlight .nb / .gp rules. */ + fill: currentColor; +} + +.gp-tmux-layout-wrapper > figcaption { + text-align: center; + font-size: 0.9em; + margin-top: 0.5rem; +} diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000000..a9f8b7195b Binary files /dev/null and b/docs/_static/favicon.ico differ diff --git a/doc/_static/img/books/amazon-logo.png b/docs/_static/img/books/amazon-logo.png similarity index 100% rename from doc/_static/img/books/amazon-logo.png rename to docs/_static/img/books/amazon-logo.png diff --git a/doc/_static/img/books/ibooks-logo.png b/docs/_static/img/books/ibooks-logo.png similarity index 100% rename from doc/_static/img/books/ibooks-logo.png rename to docs/_static/img/books/ibooks-logo.png diff --git a/docs/_static/img/icons/icon-128x128.png b/docs/_static/img/icons/icon-128x128.png new file mode 100644 index 0000000000..9b2a2ada23 Binary files /dev/null and b/docs/_static/img/icons/icon-128x128.png differ diff --git a/docs/_static/img/icons/icon-144x144.png b/docs/_static/img/icons/icon-144x144.png new file mode 100644 index 0000000000..f1012985ef Binary files /dev/null and b/docs/_static/img/icons/icon-144x144.png differ diff --git a/docs/_static/img/icons/icon-152x152.png b/docs/_static/img/icons/icon-152x152.png new file mode 100644 index 0000000000..be9154a329 Binary files /dev/null and b/docs/_static/img/icons/icon-152x152.png differ diff --git a/docs/_static/img/icons/icon-192x192.png b/docs/_static/img/icons/icon-192x192.png new file mode 100644 index 0000000000..9f0ed6e500 Binary files /dev/null and b/docs/_static/img/icons/icon-192x192.png differ diff --git a/docs/_static/img/icons/icon-384x384.png b/docs/_static/img/icons/icon-384x384.png new file mode 100644 index 0000000000..b901c6922e Binary files /dev/null and b/docs/_static/img/icons/icon-384x384.png differ diff --git a/docs/_static/img/icons/icon-512x512.png b/docs/_static/img/icons/icon-512x512.png new file mode 100644 index 0000000000..567e71d5fb Binary files /dev/null and b/docs/_static/img/icons/icon-512x512.png differ diff --git a/docs/_static/img/icons/icon-72x72.png b/docs/_static/img/icons/icon-72x72.png new file mode 100644 index 0000000000..df99774366 Binary files /dev/null and b/docs/_static/img/icons/icon-72x72.png differ diff --git a/docs/_static/img/icons/icon-96x96.png b/docs/_static/img/icons/icon-96x96.png new file mode 100644 index 0000000000..f421a709af Binary files /dev/null and b/docs/_static/img/icons/icon-96x96.png differ diff --git a/docs/_static/img/tmuxp.svg b/docs/_static/img/tmuxp.svg new file mode 100644 index 0000000000..12bf640f7f --- /dev/null +++ b/docs/_static/img/tmuxp.svg @@ -0,0 +1,216 @@ + + + + + tmuxp + + + + + + + + + + + + + + + + + + + Bottom bar shadow object (Shape) + + + Blue Square Group object (Shape) + + Blue Square shadow object (Shape) + + + Blue Square object (Shape) + + + + Yellow Square Group object (Shape) + + Yellow Square Shadow object (Shape) + + + Yellow Square object (Shape) + + + + Bottom bar object (Shape) + + + Light Blue Square Group object (Shape) + + Light Blue Square Shadow 1 object (Shape) + + + Light Blue Square object (Shape) + + + Light Blue Square Shadow 2 object (Shape) + + + + Gear object (Group) + + + + + + tmuxp + + + + diff --git a/doc/_static/tao-tmux-screenshot.png b/docs/_static/tao-tmux-screenshot.png similarity index 100% rename from doc/_static/tao-tmux-screenshot.png rename to docs/_static/tao-tmux-screenshot.png diff --git a/doc/_static/tmuxp-demo.gif b/docs/_static/tmuxp-demo.gif similarity index 100% rename from doc/_static/tmuxp-demo.gif rename to docs/_static/tmuxp-demo.gif diff --git a/doc/_static/tmuxp-dev-screenshot.png b/docs/_static/tmuxp-dev-screenshot.png similarity index 100% rename from doc/_static/tmuxp-dev-screenshot.png rename to docs/_static/tmuxp-dev-screenshot.png diff --git a/docs/_static/tmuxp-shell.gif b/docs/_static/tmuxp-shell.gif new file mode 100644 index 0000000000..26f0d36c03 Binary files /dev/null and b/docs/_static/tmuxp-shell.gif differ diff --git a/docs/_templates/book.html b/docs/_templates/book.html new file mode 100644 index 0000000000..08e43b88b9 --- /dev/null +++ b/docs/_templates/book.html @@ -0,0 +1,7 @@ +

The book!

+ +

+ +

The Tao of tmux is available on Leanpub and Kindle (Amazon).

+

Read and browse the book for free on the web.

+Amazon Kindle diff --git a/docs/about_tmux.md b/docs/about_tmux.md new file mode 100644 index 0000000000..1eee7989e1 --- /dev/null +++ b/docs/about_tmux.md @@ -0,0 +1,560 @@ +(about-tmux)= + +# The Tao of tmux + +:::{figure} /\_static/tao-tmux-screenshot.png +:scale: 60% +:align: center +:loading: lazy + +ISC-licensed terminal multiplexer. + +::: + +tmux is geared for developers and admins who interact regularly with CLI +(text-only interfaces) + +In the world of computers, there are 2 realms: + +1. The text realm +2. The graphical realm + +tmux resides in the text realm. This is about fixed-width fonts and that +old fashioned black terminal. + +tmux is to the console what a desktop is to gui apps. It's a world +inside the text dimension. Inside tmux you can: + +- multitask inside the terminal, run multiple applications. +- have multiple command lines (pane) in the same window +- have multiple windows (window) in the workspace (session) +- switch between multiple workspaces, like virtual desktops + +## Thinking tmux + +### Text-based window manager + +| **tmux** | **"Desktop"-Speak** | **Plain English** | +| ----------- | ------------------------------- | ------------------------------------- | +| Multiplexer | Multi-tasking | Multiple applications simultaneously. | +| Session | Desktop | Applications are visible here | +| Window | Virtual Desktop or applications | A desktop that stores its own screen | +| Pane | Application | Performs operations | + +:::{mermaid} +:caption: A session holds windows; each window holds one or more panes. +:alt: tmux session containing windows and panes +:name: tmux-session-window-pane-tree +:responsive: fit + +flowchart TD + session["session"] --> w1["window"] + session --> w2["window"] + session --> w3["window"] + w1 --> p1["pane"] + w1 --> p2["pane"] + w1 --> p3["pane"] + w1 --> p4["pane"] + w3 --> p5["pane"] + w3 --> p6["pane"] +::: + +- 1 {term}`Server`. + + - has 1 or more {term}`Session`. + + - has 1 or more {term}`Window`. + + - has 1 or more {term}`Pane`. + +:::{seealso} + +{ref}`glossary` has a dictionary of tmux words. + +::: + +### CLI Power Tool + +Multiple applications or terminals to run on the same screen by +splitting up 1 terminal into multiple. + +One screen can be used to edit a file, and another may be used to +`$ tail -F` a logfile. + +:::{tmux-layout} +:size: 40x10 +:layout: even-horizontal + +bash +--- +bash +::: + +:::{tmux-layout} +:size: 40x12 +:layout: tiled + +bash +--- +bash +--- +vim +--- +bash +::: + +tmux supports as many terminals as you want. + +:::{mermaid} +:caption: Switch between the windows in a session. +:alt: switching from one active tmux window to another +:name: tmux-window-switching +:responsive: fit + +flowchart TD + w1["window 1 · sys (active)"] -->|"switch-window 2"| w2["window 2 · vim (active)"] +::: + +You can switch between the windows you create. + +### Resume everything later + +You can leave tmux and all applications running (detach), log out, make +a sandwich, and re-(attach), all applications are still running! + +:::{mermaid} +:caption: Detach, leave everything running, and reattach later. +:alt: detaching from and reattaching to a running tmux session +:name: tmux-detach-reattach-cycle +:responsive: fit + +flowchart TD + running["session running"] -->|"detach · Prefix d"| detached["screen detached — apps keep running"] + detached -->|"tmux attach"| running +::: + +### Manage workflow + +- System administrators monitor logs and services. +- Programmers like to have an editor open with a CLI nearby. + +Applications running on a remote server can be launched inside of a tmux +session, detached, and reattached next time your ["train of +thought"](http://en.wikipedia.org/wiki/Train_of_thought) and work. + +Multitasking. Preserving the thinking you have. + +## Installing tmux + +tmux is packaged on most Linux and BSD systems. + +For the freshest results on how to get tmux installed on your system, +"How to install tmux on \" will do, as directions change and +are slightly different between distributions. + +tmuxp requires tmux **3.2 or newer**. The latest stable version is +viewable on the [tmux homepage](https://github.com/tmux/tmux). + +**Mac OS X** users may install the latest stable version of tmux +through [MacPorts](http://www.macports.org/), +[fink](http://fink.thetis.ig42.org/) or [Homebrew](http://www.brew.sh) +(aka brew). + +If **compiling from source**, the dependencies are +[libevent](http://www.monkey.org/~provos/libevent/) and +[ncurses](http://invisible-island.net/ncurses/). + +## Using tmux + +### Start a new session + +```console +$ tmux +``` + +That's all it takes to launch yourself into a tmux session. + +:::{admonition} Common pitfall +:class: note + +Running `$ tmux list-sessions` or any other command for listing tmux +entities (such as `$ tmux list-windows` or `$ tmux list-panes`). +This can generate the error "failed to connect to server". + +This could be because: + +- tmux server has killed its last session, killing the server. +- tmux server has encountered a crash. (tmux is highly stable, + this will rarely happen) +- tmux has not been launched yet at all. + +::: + +(prefix-key)= + +### The prefix key + +Tmux hot keys have to be pressed in a special way. **Read this +carefully**, then try it yourself. + +First, you press the _prefix_ key. This is `C-b` by default. + +Release. Then pause. For less than a second. Then type what's next. + +`C-b o` means: Press `Ctrl` and `b` at the same time. Release, Then +press `o`. + +**Remember, prefix + short cut!** `C` is `Ctrl` key. + +### Session Name + +Sessions can be _named upon creation_. + +```console +$ tmux new-session [-s session-name] +``` + +Sessions can be _renamed after creation_. + +```{eval-rst} +=============== ========================================================= +Command .. code-block:: bash + + $ tmux rename-session + +Short cut ``Prefix`` + ``$`` +=============== ========================================================= +``` + +### Window Name + +Windows can be _named upon creation_. + +```console +$ tmux new-window [-n window-name] +``` + +Windows can be _renamed after creation_. + +```{eval-rst} +=============== ========================================================== +Command .. code-block:: bash + + $ tmux rename-window + +Short cut ``Prefix`` + ``,`` +=============== ========================================================== +``` + +### Creating new windows + +```{eval-rst} +=============== ========================================================= +Command .. code-block:: bash + + $ tmux new-window [-n window-name] + +Short cut ``Prefix`` + ``c`` + + You may then rename window. +=============== ========================================================= +``` + +### Traverse windows + +By number + +```console +$ tmux select-window +``` + +Next + +```console +$ tmux next-window +``` + +Previous + +```console +$ tmux previous-window +``` + +Last-window + +```console +$ tmux last-window +``` + +| Short cut | Action | +| --------- | ----------------------------------------------------------- | +| `n` | Change to the next window. | +| `p` | Change to the previous window. | +| `w` | Choose the current window interactively. | +| `0 to 9` | Select windows 0 to 9. | +| `M-n` | Move to the next window with a bell or activity marker. | +| `M-p` | Move to the previous window with a bell or activity marker. | + +### Move windows + +Move window + +```console +$ tmux move-window [-t dst-window] +``` + +Swap the window + +```console +$ tmux swap-window [-t dst-window] +``` + +| Short cut | Action | +| --------- | ----------------------------------------------- | +| `.` | Prompt for an index to move the current window. | + +### Move panes + +```console +$ tmux move-pane [-t dst-pane] +``` + +| Short cut | Action | +| --------- | ------------------------------------------------ | +| `C-o` | Rotate the panes in the current window forwards. | +| `{` | Swap the current pane with the previous pane. | +| `}` | Swap the current pane with the next pane. | + +### Traverse panes + +Shortcut to move between panes. + +```console +$ tmux last-window +``` + +```console +$ tmux next-window +``` + +| Short cut | Action | +| ------------- | --------------------------------------------------- | +| `Up, Down` | Change to the pane above, below, to the left, or to | +| `Left, Right` | the right of the current pane. | + +Recipe: tmux conf to `hjkl` commands, add this to your `~/.tmux.conf`: + + # hjkl pane traversal + bind h select-pane -L + bind j select-pane -D + bind k select-pane -U + bind l select-pane -R + +### Kill window + +```console +$ tmux kill-window [-t target-window] +``` + +| Short cut | Action | +| --------- | ------------------------ | +| `&` | Kill the current window. | + +### Kill pane + +```console +$ tmux kill-pane [-t target-pane] +``` + +| Short cut | Action | +| --------- | ---------------------- | +| `x` | Kill the current pane. | + +### Splitting windows into panes + +```console +$ tmux split-window [-c start-directory] +``` + +Tmux windows can be split into multiple panes. + +| Short cut | Action | +| --------- | ------------------------------------------------ | +| `%` | Split the current pane into two, left and right. | +| `"` | Split the current pane into two, top and bottom. | + +## Configuring tmux + +Tmux can be configured via a `tmux(1)` configuration at `~/.tmux.conf`. + +Depending on your tmux version, there is different options available. + +### Vi-style copy and paste + +```console +# Vi copypaste mode +set-window-option -g mode-keys vi +bind-key -t vi-copy 'v' begin-selection +bind-key -t vi-copy 'y' copy-selection +``` + +### Aggressive resizing for clients + +```console +setw -g aggressive-resize on +``` + +### Reload config + +`` + `r`. + +```console +bind r source-file ~/.tmux.conf \; display-message "Config reloaded." +``` + +### Status lines + +Tmux allows configuring a status line that displays system information, +window list, and even pipe in the `stdout` of an application. + +You can use [tmux-mem-cpu-load][tmux-mem-cpu-load] to get stats (requires compilation) and +[basic-cpu-and-memory.tmux][basic-cpu-and-memory.tmux]. You can pipe in a bash command to a tmux +status line like: + +```console +$(shell-command) +``` + +So if `/usr/local/bin/tmux-mem-cpu-load` outputs stats to `stdout`, then +`$(tmux-mem-cpu-load)` is going to output the first line to the status +line. The interval is determined by the `status-interval`: + + set -g status-interval 1 + +[tmux-mem-cpu-load]: https://github.com/thewtex/tmux-mem-cpu-load +[basic-cpu-and-memory.tmux]: https://github.com/zaiste/tmuxified/blob/master/scripts/basic-cpu-and-memory.tmux + +### Examples + +- - works with tmux 1.5+. + Supports screen's `ctrl-a` `Prefix key`. Support for system cpu, + memory, uptime stats. +- Add yours, edit this page on github. + +## Reference + +### Short cuts + +:::{tip} + +{ref}`prefix-key` is pressed before a short cut! + +::: + +| Short cut | Action | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `C-b` | Send the prefix key (C-b) through to the application. | +| `C-o` | Rotate the panes in the current window forwards. | +| `C-z` | Suspend the tmux client. | +| `!` | Break the current pane out of the window. | +| `"` | Split the current pane into two, top and bottom. | +| `#` | List all paste buffers. | +| `$` | Rename the current session. | +| `%` | Split the current pane into two, left and right. | +| `&` | Kill the current window. | +| `'` | Prompt for a window index to select. | +| `,` | Rename the current window. | +| `-` | Delete the most recently copied buffer of text. | +| `.` | Prompt for an index to move the current window. | +| `0 to 9` | Select windows 0 to 9. | +| `:` | Enter the tmux command prompt. | +| `;` | Move to the previously active pane. | +| `=` | Choose which buffer to paste interactively from a list. | +| `?` | List all key bindings. | +| `D` | Choose a client to detach. | +| `[` | Enter copy mode to copy text or view the history. | +| `]` | Paste the most recently copied buffer of text. | +| `c` | Create a new window. | +| `d` | Detach the current client. | +| `f` | Prompt to search for text in open windows. | +| `i` | Display some information about the current window. | +| `l` | Move to the previously selected window. | +| `n` | Change to the next window. | +| `o` | Select the next pane in the current window. | +| `p` | Change to the previous window. | +| `q` | Briefly display pane indexes. | +| `r` | Force redraw of the attached client. | +| `s` | Select a new session for the attached client interactively. | +| `L` | Switch the attached client back to the last session. | +| `t` | Show the time. | +| `w` | Choose the current window interactively. | +| `x` | Kill the current pane. | +| `{` | Swap the current pane with the previous pane. | +| `}` | Swap the current pane with the next pane. | +| `~` | Show previous messages from tmux, if any. | +| `Page Up` | Enter copy mode and scroll one page up. | +| `Up, Down` | Change to the pane above, below, to the left, or to | +| `Left, Right` | the right of the current pane. | +| `M-1 to M-5` | Arrange panes in one of the five preset layouts: even-horizontal, even-vertical, main-horizontal, main-vertical, or tiled. | +| `M-n` | Move to the next window with a bell or activity marker. | +| `M-o` | Rotate the panes in the current window backwards. | +| `M-p` | Move to the previous window with a bell or activity marker. | +| `C-Up, C-Down` `C-Left, C-Right` | Resize the current pane in steps of one cell. | +| `M-Up, M-Down` `M-Left, M-Right` | Resize the current pane in steps of five cells. | + +Source: tmux manpage[^id2]. + +To get the text documentation of a `.1` manual file: + +```console +$ nroff -mdoc tmux.1|less +``` + +For more information on how to export and differentiate tmux between versions, see https://github.com/tmux-python/tmux-manuals. + +[^id2]: + +[creative commons by-nc-nd 3.0 us]: http://creativecommons.org/licenses/by-nc-nd/3.0/us/ + +### The Book + +:::::::{container} book-container + +::::{container} leftside-book + +:::{figure} https://s3.amazonaws.com/titlepages.leanpub.com/the-tao-of-tmux/large +:scale: 100% +:width: 301 +:height: 390 +:align: left +:target: https://leanpub.com/the-tao-of-tmux +:alt: The Tao of tmux + +::: + +:::: + +::::{container} rightside-book + +_The Tao of tmux_ is available on [Leanpub][leanpub] and [Kindle][kindle] (Amazon). + +:::{figure} \_static/img/books/amazon-logo.png +:scale: 19% +:target: http://amzn.to/2gPfRhC +:alt: Amazon Kindle + +::: + +Read and browse the book for [free on the web][free on the web]. + +:::: + +[free on the web]: https://leanpub.com/the-tao-of-tmux/read +[leanpub]: https://leanpub.com/the-tao-of-tmux +[kindle]: http://amzn.to/2gPfRhC + +::::::: + +### License + +This page is licensed [Creative Commons BY-NC-ND 3.0 US][creative commons by-nc-nd 3.0 us]. diff --git a/docs/cli/completion.md b/docs/cli/completion.md new file mode 100644 index 0000000000..4d52c74938 --- /dev/null +++ b/docs/cli/completion.md @@ -0,0 +1,98 @@ +(completion)= + +(completions)= + +(cli-completions)= + +# Completions + +Shell completion teaches your shell to finish `tmuxp` subcommands and flags when +you press Tab. It's optional, and how you set it up depends on your tmuxp +version — 1.17 and newer use [shtab], while 1.1 through 1.16 used [click]. Pick the +section that matches yours. + +## tmuxp 1.17+ (experimental) + +```{note} +See the [shtab library's documentation on shell completion](https://docs.iterative.ai/shtab/use/#cli-usage) for the most up to date way of connecting completion for tmuxp. +``` + +Provisional support for completions in tmuxp 1.17+ are powered by [shtab]. This must be **installed separately**, as it's **not currently bundled with tmuxp**. + +```console +$ pip install shtab --user +``` + +With a [uv](https://docs.astral.sh/uv/getting-started/features/#python-versions) project you can add it directly as a development dependency: + +```console +$ uv add --dev shtab +``` + +Or reach for [uvx](https://docs.astral.sh/uv/guides/tools/) when you want a pipx-style ephemeral install: + +```console +$ uvx shtab --help +``` + +:::{tab} bash + +```console +$ shtab --shell=bash -u tmuxp.cli.create_parser \ + | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP +``` + +::: + +:::{tab} zsh + +```console +$ shtab --shell=zsh -u tmuxp.cli.create_parser \ + | sudo tee /usr/local/share/zsh/site-functions/_TMUXP +``` + +::: + +:::{tab} tcsh + +```console +$ shtab --shell=tcsh -u tmuxp.cli.create_parser \ + | sudo tee /etc/profile.d/TMUXP.completion.csh +``` + +::: + +## tmuxp 1.1 to 1.16 + +```{note} +See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/) for the most up to date way of connecting completion for tmuxp. +``` + +tmuxp 1.1 to 1.16 use [click]'s completion: + +:::{tab} Bash + +_~/.bashrc_: + +```bash + +eval "$(_TMUXP_COMPLETE=bash_source tmuxp)" + +``` + +::: + +:::{tab} Zsh + +_~/.zshrc_: + +```zsh + +eval "$(_TMUXP_COMPLETE=zsh_source tmuxp)" + +``` + +::: + +[shtab]: https://docs.iterative.ai/shtab/ +[click]: https://click.palletsprojects.com diff --git a/docs/cli/convert.md b/docs/cli/convert.md new file mode 100644 index 0000000000..9c0bf1a8b7 --- /dev/null +++ b/docs/cli/convert.md @@ -0,0 +1,36 @@ +(cli-convert)= + +# tmuxp convert + +Convert workspace configuration files between YAML and JSON formats. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: convert +``` + +## Basic usage + +````{tab} YAML -> JSON + +```console +$ tmuxp convert /path/to/file.yaml +``` + +```` + +````{tab} JSON -> YAML + +```console +$ tmuxp convert /path/to/file.json +``` + +```` + +tmuxp automatically will prompt to convert `.yaml` to `.json` and +`.json` to `.yaml`. diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md new file mode 100644 index 0000000000..bd3d76c6cc --- /dev/null +++ b/docs/cli/debug-info.md @@ -0,0 +1,30 @@ +(cli-debug-info)= + +(tmuxp-debug-info)= + +# tmuxp debug-info + +Collect and display system information useful for debugging tmuxp issues and submitting bug reports. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: debug-info +``` + +## Example output + +```console + +$ tmuxp debug-info +-------------------------- +environment: + system: Linux + arch: x86_64 +... + +``` diff --git a/docs/cli/edit.md b/docs/cli/edit.md new file mode 100644 index 0000000000..4e84ca56b9 --- /dev/null +++ b/docs/cli/edit.md @@ -0,0 +1,17 @@ +(edit-config)= + +(cli-edit)= + +# tmuxp edit + +Open a workspace configuration file in your default editor for quick modifications. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: edit +``` diff --git a/docs/cli/exit-codes.md b/docs/cli/exit-codes.md new file mode 100644 index 0000000000..42486e21cf --- /dev/null +++ b/docs/cli/exit-codes.md @@ -0,0 +1,35 @@ +(cli-exit-codes)= + +# Exit Codes + +tmuxp uses standard exit codes for scripting and automation. + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error (config validation, tmux command failure) | +| `2` | Usage error (invalid arguments, missing required options) | + +## Usage in scripts + +Because the exit code is meaningful, a script can check it and react. Test it +explicitly with `$?`: + +```bash +#!/bin/bash +tmuxp load my-workspace.yaml +if [ $? -ne 0 ]; then + echo "Failed to load workspace" + exit 1 +fi +``` + +Or short-circuit with `||` to handle the failure inline: + +```bash +#!/bin/bash +tmuxp load -d my-workspace.yaml || { + echo "tmuxp failed with exit code $?" + exit 1 +} +``` diff --git a/docs/cli/freeze.md b/docs/cli/freeze.md new file mode 100644 index 0000000000..6f3f8c55ee --- /dev/null +++ b/docs/cli/freeze.md @@ -0,0 +1,45 @@ +(cli-freeze)= + +(cli-freeze-reference)= + +# tmuxp freeze + +Export a running tmux session to a workspace configuration file. This allows you to save the current state of your tmux session for later restoration. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: freeze +``` + +## Basic usage + +Freeze the current session: + +```console +$ tmuxp freeze +``` + +Freeze a specific session by name: + +```console +$ tmuxp freeze [session_name] +``` + +Overwrite an existing workspace file: + +```console +$ tmuxp freeze --force [session_name] +``` + +## Output format + +tmuxp will offer to save your session state to `.json` or `.yaml`. + +If no session is specified, it will default to the attached session. + +If the `--force` argument is passed, it will overwrite any existing workspace file with the same name. diff --git a/docs/cli/import.md b/docs/cli/import.md new file mode 100644 index 0000000000..6418de3439 --- /dev/null +++ b/docs/cli/import.md @@ -0,0 +1,77 @@ +(cli-import)= + +# tmuxp import + +Import and convert workspace configurations from other tmux session managers like +[teamocil] and [tmuxinator]. + +(import-teamocil)= + +## From teamocil + +Import teamocil configuration files and convert them to tmuxp format. + +### Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import teamocil +``` + +### Basic usage + +````{tab} YAML + +```console +$ tmuxp import teamocil /path/to/file.yaml +``` + +```` + +````{tab} JSON + +```console +$ tmuxp import teamocil /path/to/file.json +``` + +```` + +(import-tmuxinator)= + +## From tmuxinator + +Import tmuxinator configuration files and convert them to tmuxp format. + +### Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import tmuxinator +``` + +### Basic usage + +````{tab} YAML + +```console +$ tmuxp import tmuxinator /path/to/file.yaml +``` + +```` + +````{tab} JSON + +```console +$ tmuxp import tmuxinator /path/to/file.json +``` + +```` + +[teamocil]: https://github.com/remiprev/teamocil +[tmuxinator]: https://github.com/aziz/tmuxinator diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000000..fd38b681ea --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,109 @@ +(cli)= + +(commands)= + +# CLI Reference + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} tmuxp load +:link: load +:link-type: doc +Load tmux sessions from workspace configs. +::: + +:::{grid-item-card} tmuxp shell +:link: shell +:link-type: doc +Interactive Python shell with tmux context. +::: + +:::{grid-item-card} tmuxp freeze +:link: freeze +:link-type: doc +Export running sessions to config files. +::: + +:::{grid-item-card} tmuxp convert +:link: convert +:link-type: doc +Convert between YAML and JSON formats. +::: + +:::{grid-item-card} Exit Codes +:link: exit-codes +:link-type: doc +Exit codes for scripting and automation. +::: + +:::{grid-item-card} Recipes +:link: recipes +:link-type: doc +Copy-pasteable command invocations. +::: + +:::: + +```{toctree} +:caption: General commands +:maxdepth: 1 + +load +shell +ls +search +``` + +```{toctree} +:caption: Configuration +:maxdepth: 1 + +edit +import +convert +freeze +``` + +```{toctree} +:caption: Diagnostic +:maxdepth: 1 + +debug-info +``` + +```{toctree} +:caption: Completion +:maxdepth: 1 + +completion +``` + +```{toctree} +:caption: Reference +:maxdepth: 1 + +exit-codes +recipes +``` + +(cli-main)= + +(tmuxp-main)= + +## Main command + +The `tmuxp` command is the entry point for all tmuxp operations. Use subcommands to load sessions, manage configurations, and interact with tmux. + +### Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :nosubcommands: + + subparser_name : @replace + See :ref:`cli-ls` +``` diff --git a/docs/cli/load.md b/docs/cli/load.md new file mode 100644 index 0000000000..ce1ba88d5a --- /dev/null +++ b/docs/cli/load.md @@ -0,0 +1,271 @@ +(cli-load)= + +(tmuxp-load)= + +(tmuxp-load-reference)= + +# tmuxp load + +Load a tmux session from a workspace file — this is the command you reach for +most. Point it at a project directory or a YAML/JSON file and tmuxp builds the +session and attaches you to it; the flags below are for the cases where you want +something other than that default. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: load +``` + +## Basic usage + +You can load your tmuxp file and attach the tmux session via a few +shorthands: + +1. The directory with a `.tmuxp.{yaml,yml,json}` file in it +2. The name of the project file in your `$HOME/.tmuxp` folder +3. The direct path of the tmuxp file you want to load + +Path to folder with `.tmuxp.yaml`, `.tmuxp.yml`, `.tmuxp.json`: + +````{tab} Project based + +Projects with a file named `.tmuxp.yaml` or `.tmuxp.json` can be loaded: + +```console +// current directory +$ tmuxp load . +``` + +```console +$ tmuxp load ../ +``` + +```console +$ tmuxp load path/to/folder/ +``` + +```console +$ tmuxp load /path/to/folder/ +``` + +```` + +````{tab} User based + +Name of the config, assume `$HOME/.tmuxp/myconfig.yaml`: + +```console +$ tmuxp load myconfig +``` + +Direct path to json/yaml file: + +```console +$ tmuxp load ./myfile.yaml +``` + +```console +$ tmuxp load /abs/path/to/myfile.yaml +``` + +```console +$ tmuxp load ~/myfile.yaml +``` + +```` + +````{tab} Direct + +Absolute and relative directory paths are supported. + +```console +$ tmuxp load [filename] +``` + +```` + +## Inside sessions + +When you load a workspace from _inside_ an existing tmux client, tmuxp asks what +you want rather than guessing — switch to the freshly built session, append its +windows to the session you're in, or build it detached and stay put: + +``` +Already inside TMUX, switch to session? yes/no +Or (a)ppend windows in the current active session? +[y/n/a]: +``` + +:::{mermaid} +:caption: Loading from inside an existing tmux client. +:alt: tmuxp load prompt choices inside an existing tmux client +:name: tmuxp-load-inside-client +:responsive: fit + +flowchart TD + load["tmuxp load"]:::cmd --> q{"y / n / a ?"} + q -->|yes| switch["build the new session, switch the client to it"] + q -->|a| append["append its windows to the current session"] + q -->|no| detached["build it detached, stay where you are"] +::: + +## Options + +All of these options can be preselected to skip the prompt: + +- Attach / open the client after load: + + ```console + $ tmuxp load -y config + ``` + +- Detached / open in background: + + ```console + $ tmuxp load -d config + ``` + +- Append windows to existing session + + ```console + $ tmuxp load -a config + ``` + +## Loading multiple sessions + +Multiple sessions can be loaded at once. The first ones will be created +without being attached. The last one will be attached if there is no +`-d` flag on the command line. + +```console +$ tmuxp load [filename1] [filename2] ... +``` + +## Custom session name + +A session name can be provided at the terminal. If multiple sessions +are created, the last session is named from the terminal. + +```console +$ tmuxp load -s [new_session_name] [filename1] ... +``` + +## Logging + +The output of the `load` command can be logged to a file for +debugging purposes. the log level can be controlled with the global +`--log-level` option (defaults to INFO). + +```console +$ tmuxp load [filename] --log-file [log_filename] +``` + +```console +$ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] +``` + +## Progress display + +When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. + +### Presets + +Five built-in presets control the spinner format: + +| Preset | Format | +|--------|--------| +| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `minimal` | `Loading workspace: {session} [{window_progress}]` | +| `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | +| `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | +| `verbose` | `Loading workspace: {session} [window {window_index} of {window_total} · pane {pane_index} of {pane_total}] {window}` | + +Select a preset with `--progress-format`: + +```console +$ tmuxp load --progress-format minimal myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS_FORMAT=verbose tmuxp load myproject +``` + +### Custom format tokens + +Use a custom format string with any of the available tokens: + +| Token | Description | +|-------|-------------| +| `{session}` | Session name | +| `{window}` | Current window name | +| `{window_index}` | Current window number (1-based) | +| `{window_total}` | Total number of windows | +| `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | +| `{windows_done}` | Number of completed windows | +| `{windows_remaining}` | Number of remaining windows | +| `{pane_index}` | Current pane number in the window | +| `{pane_total}` | Total panes in the current window | +| `{pane_progress}` | Pane fraction (e.g. `2/4`) | +| `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | +| `{overall_percent}` | Pane-based completion percentage (0–100) | +| `{bar}` | Composite progress bar | +| `{pane_bar}` | Pane-based progress bar | +| `{window_bar}` | Window-based progress bar | +| `{status_icon}` | Status icon (⏸ during before_script) | + +Example: + +```console +$ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject +``` + +### Panel lines + +The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: + +Hide the panel entirely (script output goes to stdout): + +```console +$ tmuxp load --progress-lines 0 myproject +``` + +Show unlimited lines (capped to terminal height): + +```console +$ tmuxp load --progress-lines -1 myproject +``` + +Set a custom height (default is 3): + +```console +$ tmuxp load --progress-lines 5 myproject +``` + +### Disabling progress + +Disable the animated spinner entirely: + +```console +$ tmuxp load --no-progress myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +When progress is disabled, logging flows normally to the terminal and no spinner is rendered. + +### Before-script behavior + +During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. diff --git a/docs/cli/ls.md b/docs/cli/ls.md new file mode 100644 index 0000000000..482b86793a --- /dev/null +++ b/docs/cli/ls.md @@ -0,0 +1,17 @@ +(cli-ls)= + +(ls-config)= + +# tmuxp ls + +List available workspace configurations from your local project and global tmuxp directories. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: ls +``` diff --git a/docs/cli/recipes.md b/docs/cli/recipes.md new file mode 100644 index 0000000000..91ccd02347 --- /dev/null +++ b/docs/cli/recipes.md @@ -0,0 +1,78 @@ +(cli-recipes)= + +# Recipes + +Copy-pasteable command invocations for common tasks. Each command has a full +reference — with every flag — under {ref}`cli`. + +## Load a workspace + +```console +$ tmuxp load my-workspace.yaml +``` + +## Load in detached mode + +```console +$ tmuxp load -d my-workspace.yaml +``` + +## Load from a project directory + +```console +$ tmuxp load . +``` + +## Freeze a running session + +```console +$ tmuxp freeze my-session +``` + +## Convert YAML to JSON + +```console +$ tmuxp convert my-workspace.yaml +``` + +## Convert JSON to YAML + +```console +$ tmuxp convert my-workspace.json +``` + +## List available workspaces + +```console +$ tmuxp ls +``` + +## Search workspaces + +```console +$ tmuxp search my-project +``` + +## Edit a workspace config + +```console +$ tmuxp edit my-workspace +``` + +## Collect debug info + +```console +$ tmuxp debug-info +``` + +## Shell with tmux context + +```console +$ tmuxp shell +``` + +Access [libtmux](https://libtmux.git-pull.com/) objects directly: + +```console +$ tmuxp shell --best --command 'print(server.sessions)' +``` diff --git a/docs/cli/search.md b/docs/cli/search.md new file mode 100644 index 0000000000..2521b7de28 --- /dev/null +++ b/docs/cli/search.md @@ -0,0 +1,17 @@ +(cli-search)= + +(search-config)= + +# tmuxp search + +Search for workspace configurations by name or content across your tmuxp directories. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: search +``` diff --git a/docs/cli/shell.md b/docs/cli/shell.md new file mode 100644 index 0000000000..42b01bfb84 --- /dev/null +++ b/docs/cli/shell.md @@ -0,0 +1,128 @@ +(cli-shell)= + +(tmuxp-shell)= + +# tmuxp shell + +Launch an interactive Python shell with [libtmux] objects pre-loaded. Like +Django's `shell` command, it hands you the current tmux server, sessions, +windows, and panes already wired up, so you can poke at a live session or +prototype a script without writing boilerplate. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: shell +``` + +## Interactive usage + +Run `tmuxp shell` to drop into a Python console with the current tmux +{class}`server `, {class}`session `, +{class}`window `, and {class}`pane ` already bound. +Pass arguments to select a specific one: + +```console +(Pdb) server + +(Pdb) server.sessions +[Session($1 your_project)] +(Pdb) session +Session($1 your_project) +(Pdb) session.name +'your_project' +(Pdb) window +Window(@3 1:your_window, Session($1 your_project)) +(Pdb) window.name +'your_window' +(Pdb) window.panes +[Pane(%6 Window(@3 1:your_window, Session($1 your_project))) +(Pdb) pane +Pane(%6 Window(@3 1:your_window, Session($1 your_project))) +``` + +## Running code directly + +Pass `-c` to run a snippet and exit, much like `python -c`: + +```console +$ tmuxp shell -c 'python code' +``` + +```{image} ../_static/tmuxp-shell.gif +:width: 878 +:height: 109 +:loading: lazy +``` + +The same objects are in scope. Name a server, then a window, to narrow what the +snippet sees: + +```console +$ tmuxp shell -c 'print(session.name); print(window.name)' +my_server +my_window +``` + +```console +$ tmuxp shell my_server my_window -c 'print(window.name.upper())' +MY_WINDOW +``` + +Inside a tmux pane — or attached to the default server — the pane is in scope +too: + +```console +$ tmuxp shell -c 'print(pane.id); print(pane.window.name)' +%2 +my_window +``` + +## Debugger integration + +`tmuxp shell` supports [PEP 553][pep 553]'s `PYTHONBREAKPOINT` and compatible +debuggers, such as [ipdb][ipdb]: + +```console +$ pip install --user ipdb +``` + +Inside a [uv](https://docs.astral.sh/uv/getting-started/features/#python-versions)-managed +project, add `ipdb` as a development dependency: + +```console +$ uv add --dev ipdb +``` + +For a pipx-style ad hoc install, run it through [uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from ipdb ipdb3 --help +``` + +```console +$ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell +``` + +## Shell detection + +`tmuxp shell` drops into the richest shell available in your _site packages_. Pick +one yourself with a flag: + +- `--pdb`: plain {func}`breakpoint` (python 3.7+) or {func}`pdb.set_trace` +- `--code`: drop into {func}`code.interact`, accepts `--use-pythonrc` +- `--bpython`: drop into [bpython] +- `--ipython`: drop into [IPython] +- `--ptpython`: drop into [ptpython], accepts `--use-vi-mode` +- `--ptipython`: drop into [IPython] + [ptpython], accepts `--use-vi-mode` + +[pep 553]: https://www.python.org/dev/peps/pep-0553/ +[ipdb]: https://pypi.org/project/ipdb/ +[libtmux]: https://libtmux.git-pull.com +[bpython]: https://bpython-interpreter.org/ +[IPython]: https://ipython.org/ +[ptpython]: https://github.com/prompt-toolkit/ptpython diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000..f48447201c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,72 @@ +"""Sphinx documentation configuration for tmuxp.""" + +from __future__ import annotations + +import pathlib +import sys + +# Get the project root dir, which is the parent dir of this +cwd = pathlib.Path(__file__).parent +project_root = cwd.parent +src_root = project_root / "src" + +sys.path.insert(0, str(src_root)) +sys.path.insert(0, str(cwd / "_ext")) # for local aafig extension + +# package data +about: dict[str, str] = {} +with (src_root / "tmuxp" / "__about__.py").open() as fp: + exec(fp.read(), about) + +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config # noqa: E402 + +import tmuxp # noqa: E402 + +conf = merge_sphinx_config( + project=about["__title__"], + version=about["__version__"], + copyright=about["__copyright__"], + source_repository=f"{about['__github__']}/", + docs_url=about["__docs__"], + source_branch="master", + light_logo="img/tmuxp.svg", + dark_logo="img/tmuxp.svg", + extra_extensions=[ + "sphinx_autodoc_api_style", + "aafig", + "sphinx_gp_mermaid", + "sphinx_gp_highlighting", + "tmux_layout", + "sphinx_autodoc_argparse.exemplar", + ], + gp_highlighting_inline_literals="safe", + gp_highlighting_inline_commands=["tmuxp"], + # Route a plain ```mermaid fence to the mermaid directive (the colon and + # brace forms route there already via colon_fence). Redundant once the + # pinned gp-sphinx release auto-routes when sphinx_gp_mermaid is active; + # drop this on the next gp-sphinx bump. + myst_fence_as_directive=["mermaid"], + intersphinx_mapping={ + "python": ("https://docs.python.org/", None), + "libtmux": ("https://libtmux.git-pull.com/", None), + }, + linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), + # tmuxp-specific overrides + theme_options={"mask_icon": "/_static/img/tmuxp.svg"}, + html_extra_path=["manifest.json"], + html_favicon="_static/favicon.ico", + aafig_format={"latex": "pdf", "html": "gif"}, + aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, + rediraffe_redirects="redirects.txt", + # Keep Sphinx out of non-document trees under docs/: the mermaid toolchain + # (node_modules) and its render cache, plus agent guidance (AGENTS.md and + # its CLAUDE.md symlink) which would otherwise be orphan documents. + exclude_patterns=[ + "_build", + "node_modules", + "_mermaid_cache", + "AGENTS.md", + "CLAUDE.md", + ], +) +globals().update(conf) diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md new file mode 100644 index 0000000000..0b3f8f6260 --- /dev/null +++ b/docs/configuration/environmental-variables.md @@ -0,0 +1,85 @@ +(environmental-variables)= + +# Environmental variables + +These environment variables tune how tmuxp finds your workspaces and how much it +shows you while loading — set from your shell, outside any workspace file. You +rarely need them: tmuxp works out of the box. Reach for one when you want to +point tmuxp at a different config directory, quiet or reshape the load progress +display, or work around a rare tmux quirk. The progress variables each mirror a +{ref}`tmuxp load ` flag, noted alongside the variable. + +(TMUXP_CONFIGDIR)= + +## `TMUXP_CONFIGDIR` + +Example: `TMUXP_CONFIGDIR=$HOME/.mytmuxpconfigdir tmuxp load cpython` + +(LIBTMUX_TMUX_FORMAT_SEPARATOR)= + +## `LIBTMUX_TMUX_FORMAT_SEPARATOR` + +:::{seealso} + +{ref}`LIBTMUX_TMUX_FORMAT_SEPARATOR ` +in the libtmux API. + +::: + +In rare circumstances the `tmux -F` separator under the hood may cause issues +building sessions. For this case you can override it here. + +```console +$ env LIBTMUX_TMUX_FORMAT_SEPARATOR='__SEP__' tmuxp load [session] +``` + +(TMUXP_PROGRESS)= + +## `TMUXP_PROGRESS` + +Master on/off switch for the animated progress spinner during `tmuxp load`. +Defaults to `1` (enabled). Set to `0` to disable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +Equivalent to the `--no-progress` CLI flag. + +(TMUXP_PROGRESS_FORMAT)= + +## `TMUXP_PROGRESS_FORMAT` + +Set the spinner line format. Accepts a preset name (`default`, `minimal`, `window`, `pane`, `verbose`) or a custom format string with tokens like `{session}`, `{bar}`, `{progress}`: + +```console +$ TMUXP_PROGRESS_FORMAT=minimal tmuxp load myproject +``` + +Custom format example: + +```console +$ TMUXP_PROGRESS_FORMAT="{session} {bar} {overall_percent}%" tmuxp load myproject +``` + +Equivalent to the `--progress-format` CLI flag. + +(TMUXP_PROGRESS_LINES)= + +## `TMUXP_PROGRESS_LINES` + +Number of script-output lines shown in the spinner panel. Defaults to `3`. + +Set to `0` to hide the panel entirely (script output goes to stdout): + +```console +$ TMUXP_PROGRESS_LINES=0 tmuxp load myproject +``` + +Set to `-1` for unlimited lines (capped to terminal height): + +```console +$ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject +``` + +Equivalent to the `--progress-lines` CLI flag. diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md new file mode 100644 index 0000000000..bc3f085e47 --- /dev/null +++ b/docs/configuration/examples.md @@ -0,0 +1,783 @@ +(examples)= + +# Examples + +This is a gallery of working workspace files — each a small, complete YAML (with +its JSON twin) you can copy, load, and adapt. They run from the simplest +two-pane split up to a full development environment, and many preview the panes +they build. Skim for the feature you need — inline shorthand, blank panes, +environment variables, pane shells, focus, history, timed pauses — and lift the +snippet. + +## Short hand / inline style + +tmuxp has a short-hand syntax for those who like to keep a workspace concise. + +:::{tmux-layout} +:size: 36x12 +:layout: even-vertical + +echo 'did you know' +echo 'you can inline' +--- +echo 'single commands' +--- +echo 'for panes' +::: + +````{tab} YAML + +```{literalinclude} ../../examples/shorthands.yaml +:language: yaml + +``` + + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/shorthands.json +:language: json + +``` + +```` + +## Blank panes + +No need to repeat `pwd` or a dummy command. A `null`, `'blank'`, +`'pane'` are valid. + +Note `''` counts as an empty carriage return. + +````{tab} YAML + +```{literalinclude} ../../examples/blank-panes.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/blank-panes.json +:language: json + +``` + +```` + +## 2 panes + +:::{tmux-layout} +:size: 30x10 +:layout: even-vertical + +echo hello +--- +echo hello +::: + +````{tab} YAML + +```{literalinclude} ../../examples/2-pane-vertical.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/2-pane-vertical.json +:language: json + +``` + +```` + +## 3 panes + +:::{tmux-layout} +:size: 44x12 +:layout: main-horizontal + +cd /var/log +ls -al | grep \.log +--- +echo hello +--- +echo hello +::: + +````{tab} YAML + +```{literalinclude} ../../examples/3-pane.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/3-pane.json +:language: json + +``` + +```` + +## 4 panes + +:::{tmux-layout} +:size: 44x12 +:layout: tiled + +cd /var/log +ls -al | grep \.log +--- +echo hello +--- +echo hello +--- +echo hello +::: + +````{tab} YAML + +```{literalinclude} ../../examples/4-pane.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/4-pane.json +:language: json + +``` + +```` + +## Start Directory + +Equivalent to `tmux new-window -c `. + +````{tab} YAML + +```{literalinclude} ../../examples/start-directory.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/start-directory.json +:language: json + +``` + +```` + +## Environment variable replacing + +tmuxp will replace environment variables wrapped in curly brackets +for values of these settings: + +- `start_directory` +- `before_script` +- `session_name` +- `window_name` +- `shell_command_before` +- `global_options` +- `options` in session scope and window scope + +tmuxp replaces these variables before-hand with variables in the +terminal `tmuxp` invokes in. + +In this case of this example, assuming the username "user": + +```console +$ MY_ENV_VAR=foo tmuxp load examples/env-variables.yaml +``` + +and your session name will be `session - user (foo)`. + +Shell variables in `shell_command` do not support this type of +concatenation. `shell_command` and `shell_command_before` both +support normal shell variables, since they are sent into panes +automatically via `send-key` in `tmux(1)`. See `ls $PWD` in +example. + +If you have a special case and would like to see behavior changed, +please make a ticket on the [issue tracker][issue tracker]. + +[issue tracker]: https://github.com/tmux-python/tmuxp/issues + +````{tab} YAML + +```{literalinclude} ../../examples/env-variables.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/env-variables.json +:language: json + +``` + +```` + +## Environment variables + +tmuxp will set session, window and pane environment variables. + +```{note} + +Setting environment variables for windows and panes requires tmuxp 1.19 or newer. +``` + +````{tab} YAML + +```{literalinclude} ../../examples/session-environment.yaml +:language: yaml + +``` +```` + +````{tab} JSON + +```{literalinclude} ../../examples/session-environment.json +:language: json + +``` + +```` + +## Focusing + +tmuxp allows `focus: true` for assuring windows and panes are attached / +selected upon loading. + +````{tab} YAML + +```{literalinclude} ../../examples/focus-window-and-panes.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/focus-window-and-panes.json +:language: json + +``` + +```` + +## Terminal History + +tmuxp allows `suppress_history: false` to override the default command / +suppression when building the workspace. +This will add the `shell_command` to the shell history in the pane. +The suppression of the `shell_command` commands from the shell's history +occurs by prefixing the commands with a space when `suppress_history: true`. +Accordingly, this functionality depends on the shell being appropriately +configured: bash requires the shell variable `HISTCONTROL` to be set and +include either of the values `ignorespace` or `ignoreboth` (to also ignore +sequential duplicate commands), and zsh requires `setopt HIST_IGNORE_SPACE`. + +````{tab} YAML + +```{literalinclude} ../../examples/suppress-history.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/suppress-history.json +:language: json + +``` + +```` + +(enter)= + +## Skip command execution + +See more at {ref}`enter`. + +:::{note} + +_Experimental setting_: behavior and api is subject to change until stable. + +::: + +```{versionadded} 1.10.0 +`enter: false` option. Pane-level support. +``` + +Omit sending {kbd}`enter` to key commands. Equivalent to +{meth}`send_keys(enter=False) `. + +````{tab} YAML + +```{literalinclude} ../../examples/skip-send.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/skip-send.json +:language: json + +``` + +```` + +````{tab} YAML (pane-level) + +```{literalinclude} ../../examples/skip-send-pane-level.yaml +:language: yaml + +``` + +```` + +````{tab} JSON (pane-level) + +```{literalinclude} ../../examples/skip-send-pane-level.json +:language: json + +``` + +```` + +(sleep)= + +## Pausing commands + +:::{note} + +_Experimental setting_: behavior and api is subject to change until stable. + +::: + +```{versionadded} 1.10.0 +`sleep_before` and `sleep_after` options added. Pane and command-level support. +``` + +```{warning} +**Blocking.** This will delay loading as it runs synchronously for each pane as +{mod}`asyncio` is not implemented yet. +``` + +Omit sending {kbd}`enter` to key commands. Equivalent to having +a {func}`time.sleep` before and after {meth}`~libtmux.Pane.send_keys`. + +This is especially useful for expensive commands where the terminal needs some +breathing room (virtualenv, [poetry], [pipenv], [uv], sourcing a configuration, +launching a tui app, etc). + +````{tab} Virtualenv + +```{literalinclude} ../../examples/sleep-virtualenv.yaml +:language: yaml + +``` +```` + +````{tab} YAML + +```{literalinclude} ../../examples/sleep.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/sleep.json +:language: json + +``` + +```` + +````{tab} YAML (pane-level) + +```{literalinclude} ../../examples/sleep-pane-level.yaml +:language: yaml + +``` + +```` + +````{tab} JSON (pane-level) + +```{literalinclude} ../../examples/sleep-pane-level.json +:language: json + +``` + +```` + +## Window Index + +You can specify a window's index using the `window_index` property. Windows +without `window_index` will use the lowest available window index. + +````{tab} YAML + +```{literalinclude} ../../examples/window-index.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/window-index.json +:language: json + +``` + +```` + +## Shell per pane + +Every pane can have its own shell or application started. This allows for usage +of the `remain-on-exit` setting to be used properly, but also to have +different shells on different panes. + +````{tab} YAML + +```{literalinclude} ../../examples/pane-shell.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/pane-shell.json +:language: json + +``` + +```` + +## Set tmux options + +Works with global (server-wide) options, session options +and window options. + +Including `automatic-rename`, `default-shell`, +`default-command`, etc. + +````{tab} YAML + +```{literalinclude} ../../examples/options.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../examples/options.json +:language: json + +``` +```` + +## Set window options after pane creation + +Apply window options after panes have been created. Useful for +`synchronize-panes` option after executing individual commands in each +pane during creation. + +````{tab} YAML +```{literalinclude} ../../examples/2-pane-synchronized.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../examples/2-pane-synchronized.json +:language: json + +``` +```` + +## Main pane height + +### Percentage + +:::{versionadded} 1.46.0 + +Before this, tmuxp layouts would not detect the terminal's size. + +::: + +````{tab} YAML +```{literalinclude} ../../examples/main-pane-height-percentage.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../examples/main-pane-height-percentage.json +:language: json + +``` +```` + +### Rows + +````{tab} YAML +```{literalinclude} ../../examples/main-pane-height.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../examples/main-pane-height.json +:language: json + +``` +```` + +## Super-advanced dev environment + +:::{seealso} + +{ref}`tmuxp-developer-config` in the {ref}`developing` section. + +::: + +````{tab} YAML +```{literalinclude} ../../.tmuxp.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../.tmuxp.json +:language: json + +``` +```` + +## Multi-line commands + +You can use YAML's multiline syntax to easily split multiple +commands into the same shell command: https://stackoverflow.com/a/21699210 + +````{tab} YAML +```yaml +session_name: my project +shell_command_before: +- > + [ -d `.venv/bin/activate` ] && + source .venv/bin/activate && + reset +- sleep 1 +windows: +- window_name: first window + layout: main-horizontal + focus: true + panes: + - focus: True + - blank + - > + uv run ./manage.py migrate && + npm -C js run start + - uv run ./manage.py runserver + options: + main-pane-height: 35 +``` +```` + +## Bootstrap project before launch + +You can use `before_script` to run a script before the tmux session +starts building. This can be used to start a script to create a virtualenv +or download a virtualenv/rbenv/package.json's dependency files before +tmuxp even begins building the session. + +It works by using the [Exit Status][exit status] code returned by a script. Your +script can be any type, including bash, python, ruby, etc. + +A successful script will exit with a status of `0`. + +Important: the script file must be chmod executable `+x` or `755`. + +Run a python script (and check for its return code), the script is +_relative to the `.tmuxp.yaml`'s root_ (Windows and panes omitted in +this example): + +````{tab} YAML +```yaml +session_name: my session +before_script: ./bootstrap.py +# ... the rest of your workspace + +``` +```` + +````{tab} JSON +```json +{ + "session_name": "my session", + "before_script": "./bootstrap.py" +} + +``` +```` + +Run a shell script + check for return code on an absolute path. (Windows +and panes omitted in this example) + +````{tab} YAML + +```yaml +session_name: another example +before_script: /absolute/path/this.sh # abs path to shell script +# ... the rest of your workspace + +``` +```` + +````{tab} JSON + +```json +{ + "session_name": "my session", + "before_script": "/absolute/path/this.sh" +} + +``` +```` + +[exit status]: http://tldp.org/LDP/abs/html/exit-status.html + +## Per-project tmuxp workspaces + +You can load your software project in tmux by placing a `.tmuxp.yaml` or +`.tmuxp.json` in the project's workspace and loading it. + +Use {ref}`tmuxp load ` with an absolute filename, or `$ tmuxp load .` +when the workspace is in the current directory. + +```console + +$ tmuxp load ~/workspaces/myproject.yaml + +``` + +See examples of `tmuxp` in the wild. Have a project workspace to show off? +Edit this page. + +- +- +- + +You can use `start_directory: ./` to make the directories relative to +the workspace file / project root. + +## Bonus: pipenv auto-bootstrapping + +:::{versionadded} 1.3.4 + +`before_script` CWD's into the root (session)-level +`start_directory`. + +::: + +If you use [pipenv] / [poetry] / [uv], you can use a script like this to ensure +your packages are installed: + +````{tab} YAML + +```yaml +# assuming your .tmuxp.yaml is in your project root directory +session_name: my pipenv project +start_directory: ./ +before_script: pipenv install --dev --skip-lock # ensure dev deps install +windows: +- window_name: django project + focus: true + panes: + - blank + - pipenv run ./manage.py runserver + +``` +```` + +You can also source yourself into the virtual environment using +`shell_command_before`: + +````{tab} YAML + +```yaml +# assuming your .tmuxp.yaml is in your project root directory +session_name: my pipenv project +start_directory: ./ +before_script: pipenv install --dev --skip-lock # ensure dev deps install +shell_command_before: +- '[ -d `pipenv --venv` ] && source `pipenv --venv`/bin/activate && reset' +windows: +- window_name: django project + focus: true + panes: + - blank + - ./manage.py runserver + +``` +```` + +[pipenv]: https://docs.pipenv.org/ +[poetry]: https://python-poetry.org/ +[uv]: https://github.com/astral-sh/uv + +## Kung fu + +:::{note} + +tmuxp sessions can be scripted in python. The first way is to use the +ORM in the {ref}`API`. The second is to pass a {class}`dict` into +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` with a correct schema. +See: {func}`~tmuxp.workspace.validation.validate_schema`. + +::: + +Add yours? Submit a pull request to the [github][github] site! + +[github]: https://github.com/tmux-python/tmuxp diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000000..0cde232732 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,250 @@ +(config)= + +(configuration)= + +(workspace)= + +# Workspace files + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + +:::: + +tmuxp loads your terminal workspace into tmux using workspace files. + +The workspace file can be JSON or YAML. Its declarative style resembles tmux's object hierarchy: session, window, and panes. + +## Launching your session + +Once you have `tmuxp` installed alongside tmux, you can load a workspace with: + +```console +$ tmuxp load ./path/to/file +``` + +tmuxp will offer to assist when: + +- _Session already exists_: tmuxp will prompt you to re-attach. It does this + by checking if the workspace's `session_name` matches a session already + running on the same server. +- _When inside a tmux client_, `tmuxp` will let you create a new session and switch to it, or append the windows to your existing + session. + +## What's in a workspace file? + +1. A session name +2. A list of _windows_ +3. A list of _panes_ for each window +4. A list of _commands_ for each pane + +:::{mermaid} +:caption: A workspace file mirrors tmux's own hierarchy. +:alt: tmuxp workspace file hierarchy from session to windows, panes, and shell commands +:name: tmuxp-workspace-hierarchy +:responsive: fit + +flowchart TD + session["session_name"]:::cmd --> w1["window"] + session --> w2["window"] + w1 --> p1["pane"] + w1 --> p2["pane"] + p1 --> c1["shell_command"]:::cmd + p1 --> c2["shell_command"]:::cmd +::: + +````{tab} Basics + +```yaml +session_name: My session +windows: +- window_name: Window 1 + panes: + - shell_command: + - cmd: echo "pane 1" + - shell_command: + - cmd: echo "pane 2" +``` + +```` + +````{tab} Smallest possible + +```{literalinclude} ../../examples/minimal.yaml +:language: yaml + +``` + +As of 1.11.x. + +```` + +Breaking down the basic workspace into sections: + +1. A session name + + ```yaml + session_name: My session + ``` + +2. A list of _windows_ + + ```yaml + windows: + - window_name: Window 1 + panes: ... + # window settings + - window_name: Window 2 + panes: ... + # window settings + ``` + +3. A list of _panes_ for each window + + ```yaml + windows: + panes: + - # pane settings + - # pane settings + ``` + +4. A list of _commands_ for each pane + + ```yaml + windows: + panes: + - shell_command: + - cmd: echo "pane 1 - cmd 1" + # command options + - cmd: echo "pane 1 - cmd 2" + # command options + ``` + +## Where do I store workspace files? + +### Direct + +You can create a workspace and load it from anywhere in your file system. + +```console +$ tmuxp load [workspace-file] +``` + +````{tab} Relative +```console +$ tmuxp load ./favorites.yaml +``` +```` + +````{tab} Absolute +```console +$ tmuxp load /opt/myapp/favorites.yaml +``` +```` + +### User-based workspaces + +tmuxp uses the [XDG Base Directory] specification. + +Often on POSIX machines, you will store them in `~/.config/tmuxp`. + +Assume you store `apple.yaml` in `$XDG_CONFIG_HOME/tmuxp/apple.yaml`, you can +then use: + +```console +$ tmuxp load apple +``` + +:::{seealso} + +This path can be overridden by {ref}`TMUXP_CONFIGDIR` + +::: + +[xdg base directory]: https://specifications.freedesktop.org/basedir-spec/latest/ + +### Project-specific + +You can store a workspace in your project's root directory as `.tmuxp.yaml` or `.tmuxp.json`, then: + +Assume `.tmuxp.yaml` inside `/opt/myapp` + +```console +$ tmuxp load [workspace-file] +``` + +````{tab} In project root +```console +$ tmuxp load ./ +``` +```` + +````{tab} Absolute +```console +$ tmuxp load /opt/myapp +``` +```` + +## Reference and usage + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + +:::: + +```{toctree} +:hidden: + +top-level +environmental-variables +examples +workspace-builders +``` diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md new file mode 100644 index 0000000000..845ee79b9c --- /dev/null +++ b/docs/configuration/top-level.md @@ -0,0 +1,51 @@ +(top-level)= +(top-level-config)= + +# Top-level configuration + +Top-level keys describe the session as a whole — its name, where it starts, +the tmux options it sets — and sit above the `windows` and `panes` that fill +it. Only `session_name` is required; leave the rest out and a workspace with +just a name and a list of windows loads fine. This page covers `session_name` +and the keys for choosing a workspace builder. For the full set of session, +window, and pane keys, work through {ref}`examples`. + +## `session_name` + +The name tmux gives the session — and the name tmuxp checks against when it +decides whether that session is already running. It need not match the +workspace filename. + +For example, _apple.yaml_: + +```yaml +session_name: banana +windows: + - panes: + - +``` + +Load it detached: + +```console +$ tmuxp load ./apple.yaml -d +``` + +tmuxp reads _apple.yaml_ from the current directory and builds a tmux session +called _banana_ in the background — `-d` is detached. Attach to it with tmux +directly: + +```console +$ tmux attach -t banana +``` + +## Workspace builder keys + +A workspace file can also choose a custom builder and tune its behavior with +`workspace_builder`, `workspace_builder_paths`, and `workspace_builder_options`. +Most workspaces never set these — leave them out and you get tmuxp's built-in +classic builder. + +```{seealso} +{ref}`workspace-builders` +``` diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md new file mode 100644 index 0000000000..0630477df7 --- /dev/null +++ b/docs/configuration/workspace-builders.md @@ -0,0 +1,175 @@ +(workspace-builders)= + +# Workspace builders + +```{versionadded} 1.72.0 +``` + +A *workspace builder* is the part of tmuxp that turns a workspace configuration into a +live tmux session — it creates the session, lays out its windows and panes, and runs +their commands. You usually never have to think about it: tmuxp ships with the +built-in +{class}`classic builder `, +and your YAML or JSON workspace files load through it out of the box, just as +they always have. **Everything on this page is optional; leave a setting out to +fall back to the default.** + +:::{mermaid} +:caption: How a workspace file becomes a live tmux session. +:alt: tmuxp load reading workspace config through a workspace builder to attach a session +:name: tmuxp-workspace-builder-flow +:responsive: fit + +--- +config: + flowchart: + wrappingWidth: 500 +--- +flowchart TD + load["tmuxp load <workspace-file>"]:::cmd -->|reads workspace config| builder["Workspace Builder"] + builder -->|"creates session, windows, panes"| attach["Attach tmux session"] +::: + +Workspaces with special needs can reach for a builder's options to fine-tune how a +session loads. The classic builder, for instance, can wait for a pane's shell prompt +before sending its layout and commands — by default only when that shell is zsh (the +`pane_readiness` option). Waiting makes a session a little slower to load, but +guarantees the workspace is fully prepped before you attach. + +You can also send a workspace through a different or custom builder instead of +the classic one, and tune its options the same way. For the braver cases, you +can subclass the +{class}`classic builder ` +or write your own in Python on top of +[libtmux](https://libtmux.git-pull.com/) — see +{ref}`custom-workspace-builders` for writing, packaging, testing, and the trust +boundary that comes with running builder code. + +| Key | Type | Default | Purpose | +| --- | --- | --- | --- | +| `workspace_builder` | string | `classic` | Which builder turns the workspace into a session. | +| `workspace_builder_paths` | string or list of strings | _(none)_ | Trusted directories to import a builder from. | +| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior settings, such as `pane_readiness`. | + +(workspace-builder-key)= + +## `workspace_builder` + +This is where you name the builder. Leave it out — or set `classic` — and you get +tmuxp's built-in builder, with nothing imported. When you name something else, tmuxp +works out what you mean from the shape of the value, in this order: + +1. absent or empty → the built-in classic builder (nothing is imported); +2. contains `:` → a `module:attr` object reference; +3. no `.` and no `:` → a builder registered under the `tmuxp.workspace_builders` + entry-point group, selected by name; +4. dotted with no `:` → an entry-point name if one is registered, otherwise a + `module.attr` import path. + +```yaml +session_name: my-session +workspace_builder: classic +windows: + - panes: + - vim +``` + +See {ref}`custom-workspace-builders` for selecting and packaging builders, and +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` for the resolver. + +(workspace-builder-paths-key)= + +## `workspace_builder_paths` + +When your builder lives outside tmuxp's environment — say, a script sitting in your +config directory — this tells tmuxp where to find it. Give it a single directory or a +list of them. tmuxp expands `~` and environment variables, reads relative entries +against the workspace file's own directory, and expects each one to be a directory +that already exists. The paths join {data}`sys.path` only for the import and +build, not for the rest of your session. + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +```{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. See the security note in {ref}`custom-workspace-builders`. +``` + +(workspace-builder-options-key)= + +## `workspace_builder_options` + +This holds builder-behavior settings, whichever builder you use. For now there's just +one, `pane_readiness`, which decides whether tmuxp waits for a pane's shell prompt +before it sends that pane's layout and commands — a guard against a zsh prompt-redraw +artifact: + +```yaml +workspace_builder_options: + pane_readiness: auto +``` + +| Value | Behavior | +| --- | --- | +| `auto` _(default)_ | Wait only when the session's shell is zsh. | +| `always` | Always wait for default-shell panes. | +| `never` | Never wait; fastest, but accepts the prompt/layout race for shells that need it. | + +`pane_readiness` also accepts truthy/falsy aliases — `true`/`on`/`yes`/`1` map to +`always`, and `false`/`off`/`no`/`0` map to `never` (full list in +{ref}`custom-workspace-builders`). An unrecognized value fails the load with: + +```text +invalid pane_readiness value: 'sometimes'; expected one of: auto, always/true/on/yes/1, never/false/off/no/0 +``` + +A pane that runs a custom `shell` or `window_shell` never waits, whatever you set here. +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Minimal complete example + +````{tab} YAML +```yaml +session_name: my-session +workspace_builder: classic +workspace_builder_paths: + - ~/.config/tmuxp/builders +workspace_builder_options: + pane_readiness: auto +windows: + - window_name: editor + panes: + - vim +``` +```` + +````{tab} JSON +```json +{ + "session_name": "my-session", + "workspace_builder": "classic", + "workspace_builder_paths": ["~/.config/tmuxp/builders"], + "workspace_builder_options": { + "pane_readiness": "auto" + }, + "windows": [ + { + "window_name": "editor", + "panes": ["vim"] + } + ] +} +``` +```` + +```{seealso} +{ref}`custom-workspace-builders` — narrative guide to selecting, packaging, +writing, and testing builders · +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` · +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +``` diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000..bac56bd12c --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,64 @@ +(glossary)= + +# Glossary + +```{glossary} + +tmuxp + A tool to manage workspaces with tmux. A pythonic abstraction of + tmux. + +tmux +tmux(1) + The tmux binary. Used internally to distinguish tmuxp is only a + layer on top of tmux. + +ConfigReader + The {class}`~tmuxp._internal.config_reader.ConfigReader` configuration + management class, for parsing YAML / JSON / etc. files + to and from python data (dictionaries, in the future, potentially + dataclasses) + +Server + tmux runs in the background of your system as a process. + + A server holds one or more {term}`Session`. tmux starts the server + automatically the first time ``$ tmux`` runs, if it isn't already + running. + + Advanced: you can run more than one by specifying + ``[-L socket-name]`` or ``[-S socket-path]``. + +Client + Attaches to a tmux {term}`server`. When you use tmux through CLI, + you are using tmux as a client. + +Session + Inside a tmux {term}`server`. + + The session has 1 or more {term}`Window`. The bottom bar in tmux + show a list of windows. Normally they can be navigated with + ``Ctrl-a [0-9]``, ``Ctrl-a n`` and ``Ctrl-a p``. + + Sessions can have a ``session_name``. + + Uniquely identified by ``session_id``. + +Window + Entity of a {term}`session`. + + Can have 1 or more {term}`pane`. + + Panes can be organized with layouts. + + Windows can have names. + +Pane + Linked to a {term}`Window`. + + a pseudoterminal. + +Target + A target, cited in the manual as ``[-t target]`` can be a session, + window or pane. +``` diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000000..55bbcbc445 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,7 @@ +(changelog)= + +(history)= + +```{include} ../CHANGES + +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..ddacba1bb4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,108 @@ +(index)= + +# tmuxp + +Session manager for tmux. Load, freeze, and convert tmux sessions through +YAML/JSON configuration files. Powered by [libtmux](https://libtmux.git-pull.com/). + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Quickstart +:link: quickstart +:link-type: doc +Install and run your first command. +::: + +:::{grid-item-card} CLI Reference +:link: cli/index +:link-type: doc +Every command, flag, and option. +::: + +:::{grid-item-card} Configuration +:link: configuration/index +:link-type: doc +Config format, examples, and environment variables. +::: + +:::: + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Topics +:link: topics/index +:link-type: doc +Workflows, plugins, and troubleshooting. +::: + +:::{grid-item-card} Contributing +:link: project/index +:link-type: doc +Internals, development setup, and release process. +::: + +:::: + +## Install + +```console +$ pip install tmuxp +``` + +```console +$ uv tool install tmuxp +``` + +```console +$ brew install tmuxp +``` + +See [Quickstart](quickstart.md) for all installation methods and first steps. + +## Load a workspace + +```yaml +session_name: my-project +windows: + - window_name: editor + panes: + - shell_command: + - vim + - shell_command: + - git status +``` + +```console +$ tmuxp load my-project.yaml +``` + +```{image} _static/tmuxp-demo.gif +:width: 888 +:height: 589 +:loading: lazy +``` + +```{toctree} +:hidden: + +quickstart +cli/index +configuration/index +topics/index +internals/index +project/index +history +``` + +```{toctree} +:hidden: +:caption: More + +about_tmux +migration +glossary +MCP +GitHub +``` diff --git a/docs/internals/api/_internal/colors.md b/docs/internals/api/_internal/colors.md new file mode 100644 index 0000000000..b3577d9bae --- /dev/null +++ b/docs/internals/api/_internal/colors.md @@ -0,0 +1,14 @@ +# Colors - `tmuxp._internal.colors` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.colors + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/_internal/config_reader.md b/docs/internals/api/_internal/config_reader.md new file mode 100644 index 0000000000..fa932f8643 --- /dev/null +++ b/docs/internals/api/_internal/config_reader.md @@ -0,0 +1,14 @@ +# Config reader - `tmuxp._internal.config_reader` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.config_reader + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/_internal/index.md b/docs/internals/api/_internal/index.md new file mode 100644 index 0000000000..391a80b60f --- /dev/null +++ b/docs/internals/api/_internal/index.md @@ -0,0 +1,16 @@ +(api-internal)= + +# Internal Modules + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{toctree} +colors +config_reader +private_path +types +``` diff --git a/docs/internals/api/_internal/private_path.md b/docs/internals/api/_internal/private_path.md new file mode 100644 index 0000000000..d329e15169 --- /dev/null +++ b/docs/internals/api/_internal/private_path.md @@ -0,0 +1,14 @@ +# Private path - `tmuxp._internal.private_path` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.private_path + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/_internal/types.md b/docs/internals/api/_internal/types.md new file mode 100644 index 0000000000..f41f12b6d1 --- /dev/null +++ b/docs/internals/api/_internal/types.md @@ -0,0 +1,8 @@ +# Typings - `tmuxp._internal.types` + +```{eval-rst} +.. automodule:: tmuxp._internal.types + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/convert.md b/docs/internals/api/cli/convert.md new file mode 100644 index 0000000000..4c9b87f34f --- /dev/null +++ b/docs/internals/api/cli/convert.md @@ -0,0 +1,8 @@ +# tmuxp convert - `tmuxp.cli.convert` + +```{eval-rst} +.. automodule:: tmuxp.cli.convert + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/debug_info.md b/docs/internals/api/cli/debug_info.md new file mode 100644 index 0000000000..56a50052e6 --- /dev/null +++ b/docs/internals/api/cli/debug_info.md @@ -0,0 +1,8 @@ +# tmuxp debug-info - `tmuxp.cli.debug_info` + +```{eval-rst} +.. automodule:: tmuxp.cli.debug_info + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/edit.md b/docs/internals/api/cli/edit.md new file mode 100644 index 0000000000..704e8144c3 --- /dev/null +++ b/docs/internals/api/cli/edit.md @@ -0,0 +1,8 @@ +# tmuxp edit - `tmuxp.cli.edit` + +```{eval-rst} +.. automodule:: tmuxp.cli.edit + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/formatter.md b/docs/internals/api/cli/formatter.md new file mode 100644 index 0000000000..7aa37596c5 --- /dev/null +++ b/docs/internals/api/cli/formatter.md @@ -0,0 +1,8 @@ +# tmuxp formatter - `tmuxp.cli._formatter` + +```{eval-rst} +.. automodule:: tmuxp.cli._formatter + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/freeze.md b/docs/internals/api/cli/freeze.md new file mode 100644 index 0000000000..70c4d06845 --- /dev/null +++ b/docs/internals/api/cli/freeze.md @@ -0,0 +1,8 @@ +# tmuxp freeze - `tmuxp.cli.freeze` + +```{eval-rst} +.. automodule:: tmuxp.cli.freeze + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/import_config.md b/docs/internals/api/cli/import_config.md new file mode 100644 index 0000000000..3c75e88ead --- /dev/null +++ b/docs/internals/api/cli/import_config.md @@ -0,0 +1,8 @@ +# tmuxp import - `tmuxp.cli.import_config` + +```{eval-rst} +.. automodule:: tmuxp.cli.import_config + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/index.md b/docs/internals/api/cli/index.md new file mode 100644 index 0000000000..851f620586 --- /dev/null +++ b/docs/internals/api/cli/index.md @@ -0,0 +1,33 @@ +(api_cli)= + +# CLI + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{toctree} +convert +debug_info +edit +freeze +formatter +import_config +load +ls +progress +search +shell +utils +``` + +## `tmuxp.cli` + +```{eval-rst} +.. automodule:: tmuxp.cli + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/load.md b/docs/internals/api/cli/load.md new file mode 100644 index 0000000000..bae0f6a899 --- /dev/null +++ b/docs/internals/api/cli/load.md @@ -0,0 +1,8 @@ +# tmuxp load - `tmuxp.cli.load` + +```{eval-rst} +.. automodule:: tmuxp.cli.load + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/ls.md b/docs/internals/api/cli/ls.md new file mode 100644 index 0000000000..34d27718a6 --- /dev/null +++ b/docs/internals/api/cli/ls.md @@ -0,0 +1,8 @@ +# tmuxp ls - `tmuxp.cli.ls` + +```{eval-rst} +.. automodule:: tmuxp.cli.ls + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/progress.md b/docs/internals/api/cli/progress.md new file mode 100644 index 0000000000..3b092349cf --- /dev/null +++ b/docs/internals/api/cli/progress.md @@ -0,0 +1,8 @@ +# tmuxp progress - `tmuxp.cli._progress` + +```{eval-rst} +.. automodule:: tmuxp.cli._progress + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/search.md b/docs/internals/api/cli/search.md new file mode 100644 index 0000000000..bb9747e9d5 --- /dev/null +++ b/docs/internals/api/cli/search.md @@ -0,0 +1,8 @@ +# tmuxp search - `tmuxp.cli.search` + +```{eval-rst} +.. automodule:: tmuxp.cli.search + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/shell.md b/docs/internals/api/cli/shell.md new file mode 100644 index 0000000000..c6f05d0874 --- /dev/null +++ b/docs/internals/api/cli/shell.md @@ -0,0 +1,8 @@ +# tmuxp shell - `tmuxp.cli.shell` + +```{eval-rst} +.. automodule:: tmuxp.cli.shell + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/utils.md b/docs/internals/api/cli/utils.md new file mode 100644 index 0000000000..7a110a570c --- /dev/null +++ b/docs/internals/api/cli/utils.md @@ -0,0 +1,9 @@ +# CLI utilities - `tmuxp.cli.utils` + +```{eval-rst} +.. automodule:: tmuxp.cli.utils + :members: + :exclude-members: ColorMode + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/exc.md b/docs/internals/api/exc.md new file mode 100644 index 0000000000..70b68a2466 --- /dev/null +++ b/docs/internals/api/exc.md @@ -0,0 +1,8 @@ +# Exceptions - `tmuxp.exc` + +```{eval-rst} +.. automodule:: tmuxp.exc + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/index.md b/docs/internals/api/index.md new file mode 100644 index 0000000000..287bd0ba7f --- /dev/null +++ b/docs/internals/api/index.md @@ -0,0 +1,86 @@ +(api)= + +# API Reference + +This is the internal Python API — the modules tmuxp's CLI is built from, +documented for contributors and plugin authors. Everyday use goes through the +{ref}`CLI `; to drive tmux from Python directly, reach for +[libtmux](https://libtmux.git-pull.com/) instead. + +:::{seealso} +See {ref}`libtmux's API ` and {ref}`Quickstart ` to see how you can control +tmux via python API calls. +::: + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Workspace +:link: workspace/index +:link-type: doc +Finding, loading, building, and freezing sessions. +::: + +:::{grid-item-card} CLI modules +:link: cli/index +:link-type: doc +The {mod}`argparse` commands behind each subcommand. +::: + +:::{grid-item-card} Plugin API +:link: plugin +:link-type: doc +The {class}`~tmuxp.plugin.TmuxpPlugin` base class and its hooks. +::: + +:::{grid-item-card} Internal helpers +:link: _internal/index +:link-type: doc +Config reader, colors, and private path helpers. +::: + +:::{grid-item-card} Exceptions +:link: exc +:link-type: doc +The {exc}`~tmuxp.exc.TmuxpException` hierarchy. +::: + +:::{grid-item-card} Logging +:link: log +:link-type: doc +Loggers and user-facing echo helpers. +::: + +:::{grid-item-card} Shell +:link: shell +:link-type: doc +Internals of the interactive shell launcher. +::: + +:::{grid-item-card} Utilities +:link: util +:link-type: doc +Assorted helpers used across tmuxp. +::: + +:::{grid-item-card} Types +:link: types +:link-type: doc +Shared type definitions. +::: + +:::: + +```{toctree} +:hidden: + +_internal/index +cli/index +workspace/index +exc +log +plugin +shell +util +types +``` diff --git a/docs/internals/api/log.md b/docs/internals/api/log.md new file mode 100644 index 0000000000..2e000328c4 --- /dev/null +++ b/docs/internals/api/log.md @@ -0,0 +1,8 @@ +# Logging - `tmuxp.log` + +```{eval-rst} +.. automodule:: tmuxp.log + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/plugin.md b/docs/internals/api/plugin.md new file mode 100644 index 0000000000..fd7ce9781c --- /dev/null +++ b/docs/internals/api/plugin.md @@ -0,0 +1,8 @@ +# Plugin - `tmuxp.plugin` + +```{eval-rst} +.. automodule:: tmuxp.plugin + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/shell.md b/docs/internals/api/shell.md new file mode 100644 index 0000000000..286974a67e --- /dev/null +++ b/docs/internals/api/shell.md @@ -0,0 +1,8 @@ +# Shell - `tmuxp.shell` + +```{eval-rst} +.. automodule:: tmuxp.shell + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/types.md b/docs/internals/api/types.md new file mode 100644 index 0000000000..adac7ee4d9 --- /dev/null +++ b/docs/internals/api/types.md @@ -0,0 +1,8 @@ +# Typings - `tmuxp.types` + +```{eval-rst} +.. automodule:: tmuxp.types + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/util.md b/docs/internals/api/util.md new file mode 100644 index 0000000000..80e067e169 --- /dev/null +++ b/docs/internals/api/util.md @@ -0,0 +1,8 @@ +# Utilities - `tmuxp.util` + +```{eval-rst} +.. automodule:: tmuxp.util + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/classic.md b/docs/internals/api/workspace/builder/classic.md new file mode 100644 index 0000000000..856ebc63d4 --- /dev/null +++ b/docs/internals/api/workspace/builder/classic.md @@ -0,0 +1,8 @@ +# Classic builder - `tmuxp.workspace.builder.classic` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.classic + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/index.md b/docs/internals/api/workspace/builder/index.md new file mode 100644 index 0000000000..114a511fa9 --- /dev/null +++ b/docs/internals/api/workspace/builder/index.md @@ -0,0 +1,44 @@ +# Builder - `tmuxp.workspace.builder` + +```{eval-rst} +.. py:module:: tmuxp.workspace.builder +``` + +`tmuxp.workspace.builder` is a package. The classic, default builder lives in +{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection +machinery live alongside it. + +{class}`WorkspaceBuilder ` +remains importable from {mod}`tmuxp.workspace.builder` as a backwards-compatible +alias of {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Classic builder +:link: classic +:link-type: doc +The built-in, default builder — `tmuxp.workspace.builder.classic`. +::: + +:::{grid-item-card} Builder protocol +:link: protocol +:link-type: doc +The contract a builder must satisfy — `tmuxp.workspace.builder.protocol`. +::: + +:::{grid-item-card} Builder registry +:link: registry +:link-type: doc +Builder selection and trusted import paths — `tmuxp.workspace.builder.registry`. +::: + +:::: + +```{toctree} +:hidden: + +classic +protocol +registry +``` diff --git a/docs/internals/api/workspace/builder/protocol.md b/docs/internals/api/workspace/builder/protocol.md new file mode 100644 index 0000000000..1c980b33b3 --- /dev/null +++ b/docs/internals/api/workspace/builder/protocol.md @@ -0,0 +1,8 @@ +# Builder protocol - `tmuxp.workspace.builder.protocol` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.protocol + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/registry.md b/docs/internals/api/workspace/builder/registry.md new file mode 100644 index 0000000000..3a9fa4f7f3 --- /dev/null +++ b/docs/internals/api/workspace/builder/registry.md @@ -0,0 +1,8 @@ +# Builder registry - `tmuxp.workspace.builder.registry` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.registry + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/constants.md b/docs/internals/api/workspace/constants.md new file mode 100644 index 0000000000..df8cf583f2 --- /dev/null +++ b/docs/internals/api/workspace/constants.md @@ -0,0 +1,8 @@ +# Constants - `tmuxp.workspace.constants` + +```{eval-rst} +.. automodule:: tmuxp.workspace.constants + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/finders.md b/docs/internals/api/workspace/finders.md new file mode 100644 index 0000000000..d48318c361 --- /dev/null +++ b/docs/internals/api/workspace/finders.md @@ -0,0 +1,8 @@ +# Finders - `tmuxp.workspace.finders` + +```{eval-rst} +.. automodule:: tmuxp.workspace.finders + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/freezer.md b/docs/internals/api/workspace/freezer.md new file mode 100644 index 0000000000..ec9afd873a --- /dev/null +++ b/docs/internals/api/workspace/freezer.md @@ -0,0 +1,8 @@ +# Freezer - `tmuxp.workspace.freezer` + +```{eval-rst} +.. automodule:: tmuxp.workspace.freezer + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/importers.md b/docs/internals/api/workspace/importers.md new file mode 100644 index 0000000000..b3b9cd00db --- /dev/null +++ b/docs/internals/api/workspace/importers.md @@ -0,0 +1,8 @@ +# Importers - `tmuxp.workspace.importers` + +```{eval-rst} +.. automodule:: tmuxp.workspace.importers + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/index.md b/docs/internals/api/workspace/index.md new file mode 100644 index 0000000000..73c844cf27 --- /dev/null +++ b/docs/internals/api/workspace/index.md @@ -0,0 +1,20 @@ +(workspace)= + +# Workspace + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{toctree} +builder/index +constants +finders +freezer +importers +loader +options +validation +``` diff --git a/docs/internals/api/workspace/loader.md b/docs/internals/api/workspace/loader.md new file mode 100644 index 0000000000..f422644ab0 --- /dev/null +++ b/docs/internals/api/workspace/loader.md @@ -0,0 +1,8 @@ +# Loader - `tmuxp.workspace.loader` + +```{eval-rst} +.. automodule:: tmuxp.workspace.loader + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/options.md b/docs/internals/api/workspace/options.md new file mode 100644 index 0000000000..e492ce6728 --- /dev/null +++ b/docs/internals/api/workspace/options.md @@ -0,0 +1,8 @@ +# Options - `tmuxp.workspace.options` + +```{eval-rst} +.. automodule:: tmuxp.workspace.options + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/validation.md b/docs/internals/api/workspace/validation.md new file mode 100644 index 0000000000..77f7655e80 --- /dev/null +++ b/docs/internals/api/workspace/validation.md @@ -0,0 +1,8 @@ +# Validation - `tmuxp.workspace.validation` + +```{eval-rst} +.. automodule:: tmuxp.workspace.validation + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000000..0156f25eb5 --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,54 @@ +# Architecture + +This page traces how a tmuxp command travels from the CLI down to +[libtmux](https://libtmux.git-pull.com/), the library that does the actual tmux +work. Each subcommand takes its own short path through one or two workspace +modules: + +:::{mermaid} +:caption: How each tmuxp subcommand reaches libtmux. +:alt: tmuxp CLI subcommands reaching workspace modules and libtmux +:name: tmuxp-command-architecture +:responsive: fit + +flowchart TD + cli["tmuxp CLI (argparse)"] + cli --> load["load"]:::cmd + load --> loader["workspace.loader"]:::cmd + loader --> builder["workspace.builder"]:::cmd + builder --> libtmux["libtmux"]:::cmd + cli --> freeze["freeze"]:::cmd + freeze --> freezer["workspace.freezer"]:::cmd + freezer --> libtmux + cli --> convert["convert"]:::cmd + convert --> reader["_internal.config_reader"]:::cmd + cli --> shell["shell"]:::cmd + shell --> interactive["libtmux (interactive)"] + cli --> search["ls / search"]:::cmd + search --> finders["workspace.finders"]:::cmd +::: + +## Key Components + +### CLI Layer (`tmuxp.cli`) + +The CLI uses Python's {mod}`argparse` with a custom formatter ({mod}`tmuxp.cli._formatter`). +Each subcommand lives in its own module under {mod}`tmuxp.cli`. + +The entry point is {func}`tmuxp.cli.cli`, registered as a console script in `pyproject.toml`. + +### Workspace Layer (`tmuxp.workspace`) + +The workspace layer handles configuration lifecycle: + +1. **Finding**: {mod}`tmuxp.workspace.finders` locates config files +2. **Loading**: {mod}`tmuxp.workspace.loader` reads and validates configs +3. **Building**: {mod}`tmuxp.workspace.builder` creates tmux sessions via libtmux +4. **Freezing**: {mod}`tmuxp.workspace.freezer` exports running sessions + +### Library Layer (libtmux) + +tmuxp delegates all tmux operations to [libtmux](https://libtmux.git-pull.com/). +The {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` creates +libtmux {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, +and {class}`~libtmux.Pane` objects to construct the requested workspace. diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000000..7c63fa86d0 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,36 @@ +(internals)= + +# Internals + +```{warning} +Everything in this section is **internal implementation detail**. There is +no stability guarantee. Interfaces may change or be removed without notice +between any release. + +If you are building an application with tmuxp, use the [CLI](../cli/index.md) +or refer to the {ref}`libtmux API `. +``` + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Architecture +:link: architecture +:link-type: doc +How the CLI dispatches to the workspace builder and libtmux. +::: + +:::{grid-item-card} Python API +:link: api/index +:link-type: doc +Internal module reference for contributors and plugin authors. +::: + +:::: + +```{toctree} +:hidden: + +architecture +api/index +``` diff --git a/docs/justfile b/docs/justfile new file mode 100644 index 0000000000..afeec6b509 --- /dev/null +++ b/docs/justfile @@ -0,0 +1,210 @@ +# justfile for tmuxp documentation +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# Configuration +http_port := "8031" +builddir := "_build" +sphinxopts := "" +sphinxbuild := "uv run sphinx-build" +sourcedir := "." + +# Environment to disable colors in Sphinx + argparse output +export PYTHON_COLORS := "0" +export NO_COLOR := "1" + +# File patterns for watching +watch_files := "find .. -type f -not -path '*/\\.*' | grep -i '.*[.]\\(rst\\|md\\)$\\|.*[.]py$\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# Sphinx options +allsphinxopts := "-d " + builddir + "/doctrees " + sphinxopts + " ." + +# List all available commands +default: + @just --list + +# Build HTML documentation +[group: 'build'] +html: + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/html + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/html." + +# Build directory HTML files +[group: 'build'] +dirhtml: + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/dirhtml + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/dirhtml." + +# Build single HTML file +[group: 'build'] +singlehtml: + {{ sphinxbuild }} -b singlehtml {{ allsphinxopts }} {{ builddir }}/singlehtml + @echo "" + @echo "Build finished. The HTML page is in {{ builddir }}/singlehtml." + +# Build EPUB +[group: 'build'] +epub: + {{ sphinxbuild }} -b epub {{ allsphinxopts }} {{ builddir }}/epub + @echo "" + @echo "Build finished. The epub file is in {{ builddir }}/epub." + +# Build LaTeX files +[group: 'build'] +latex: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "" + @echo "Build finished; the LaTeX files are in {{ builddir }}/latex." + +# Build PDF via LaTeX +[group: 'build'] +latexpdf: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "Running LaTeX files through pdflatex..." + make -C {{ builddir }}/latex all-pdf + @echo "pdflatex finished; the PDF files are in {{ builddir }}/latex." + +# Build plain text files +[group: 'build'] +text: + {{ sphinxbuild }} -b text {{ allsphinxopts }} {{ builddir }}/text + @echo "" + @echo "Build finished. The text files are in {{ builddir }}/text." + +# Build man pages +[group: 'build'] +man: + {{ sphinxbuild }} -b man {{ allsphinxopts }} {{ builddir }}/man + @echo "" + @echo "Build finished. The manual pages are in {{ builddir }}/man." + +# Build JSON output +[group: 'build'] +json: + {{ sphinxbuild }} -b json {{ allsphinxopts }} {{ builddir }}/json + @echo "" + @echo "Build finished; now you can process the JSON files." + +# Clean build directory +[group: 'misc'] +[confirm] +clean: + rm -rf {{ builddir }}/* + +# Build HTML help files +[group: 'misc'] +htmlhelp: + {{ sphinxbuild }} -b htmlhelp {{ allsphinxopts }} {{ builddir }}/htmlhelp + @echo "" + @echo "Build finished; now you can run HTML Help Workshop with the .hhp project file in {{ builddir }}/htmlhelp." + +# Build Qt help files +[group: 'misc'] +qthelp: + {{ sphinxbuild }} -b qthelp {{ allsphinxopts }} {{ builddir }}/qthelp + @echo "" + @echo "Build finished; now you can run 'qcollectiongenerator' with the .qhcp project file in {{ builddir }}/qthelp." + +# Build Devhelp files +[group: 'misc'] +devhelp: + {{ sphinxbuild }} -b devhelp {{ allsphinxopts }} {{ builddir }}/devhelp + @echo "" + @echo "Build finished." + +# Build Texinfo files +[group: 'misc'] +texinfo: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "" + @echo "Build finished. The Texinfo files are in {{ builddir }}/texinfo." + +# Build Info files from Texinfo +[group: 'misc'] +info: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C {{ builddir }}/texinfo info + @echo "makeinfo finished; the Info files are in {{ builddir }}/texinfo." + +# Build gettext catalogs +[group: 'misc'] +gettext: + {{ sphinxbuild }} -b gettext {{ sphinxopts }} . {{ builddir }}/locale + @echo "" + @echo "Build finished. The message catalogs are in {{ builddir }}/locale." + +# Check all external links +[group: 'validate'] +linkcheck: + {{ sphinxbuild }} -b linkcheck {{ allsphinxopts }} {{ builddir }}/linkcheck + @echo "" + @echo "Link check complete; look for any errors in the above output or in {{ builddir }}/linkcheck/output.txt." + +# Run doctests embedded in documentation +[group: 'validate'] +doctest: + {{ sphinxbuild }} -b doctest {{ allsphinxopts }} {{ builddir }}/doctest + @echo "Testing of doctests in the sources finished, look at the results in {{ builddir }}/doctest/output.txt." + +# Check build from scratch +[group: 'validate'] +checkbuild: + rm -rf {{ builddir }} + {{ sphinxbuild }} -n -q ./ {{ builddir }} + +# Build redirects configuration +[group: 'misc'] +redirects: + {{ sphinxbuild }} -b rediraffewritediff {{ allsphinxopts }} {{ builddir }}/redirects + @echo "" + @echo "Build finished. The redirects are in rediraffe_redirects." + +# Show changes overview +[group: 'misc'] +changes: + {{ sphinxbuild }} -b changes {{ allsphinxopts }} {{ builddir }}/changes + @echo "" + @echo "The overview file is in {{ builddir }}/changes." + +# Watch files and rebuild on change +[group: 'dev'] +watch: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + ${{ watch_files }} | entr -c just html + else + just html + fi + +# Serve documentation via Python http.server +[group: 'dev'] +serve: + @echo '==============================================================' + @echo '' + @echo 'docs server running at http://localhost:{{ http_port }}/' + @echo '' + @echo '==============================================================' + python -m http.server {{ http_port }} --directory {{ builddir }}/html + +# Watch and serve simultaneously +[group: 'dev'] +dev: + #!/usr/bin/env bash + set -euo pipefail + just watch & + just serve + +# Start sphinx-autobuild server +[group: 'dev'] +start: + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} + +# Design mode: watch static files and disable incremental builds +[group: 'dev'] +design: + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a diff --git a/docs/manifest.json b/docs/manifest.json new file mode 100644 index 0000000000..d691c43cb3 --- /dev/null +++ b/docs/manifest.json @@ -0,0 +1,53 @@ +{ + "name": "tmuxp", + "short_name": "tmuxp", + "description": "tmux session manager", + "theme_color": "#2196f3", + "background_color": "#fff", + "display": "browser", + "Scope": "https://tmuxp.git-pull.com/", + "start_url": "https://tmuxp.git-pull.com/", + "icons": [ + { + "src": "_static/img/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "_static/img/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "splash_pages": null +} diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000000..7bd3f4664f --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,9 @@ +(migration)= + +```{currentmodule} libtmux + +``` + +```{include} ../MIGRATION + +``` diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..c86f31b7a0 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,8 @@ +{ + "name": "tmuxp-docs", + "private": true, + "description": "Build-time tooling for the tmuxp docs (mermaid diagram rendering).", + "devDependencies": { + "@mermaid-js/mermaid-cli": "11.15.0" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000000..fa55bb633c --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,2281 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@mermaid-js/mermaid-cli': + specifier: 11.15.0 + version: 11.15.0(puppeteer@24.43.1)(tailwindcss@4.3.1) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fortawesome/fontawesome-free@7.3.0': + resolution: {integrity: sha512-Yw4Qa43P6e4Xwh0TwEUkHQNRJInWaqIBo73VJmns3j2AIlZPAvUcR4yGIxCPqPRCgyJ5KIVIalF/I1GxhZ/Kgw==} + engines: {node: '>=6'} + + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + '@headlessui/tailwindcss@0.2.2': + resolution: {integrity: sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==} + engines: {node: '>=10'} + peerDependencies: + tailwindcss: ^3.0 || ^4.0 + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.9': + resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + + '@mermaid-js/layout-elk@0.2.2': + resolution: {integrity: sha512-vnH3gtqfhyBiRVKNpT8iDENTw18q/OF0GF/SfYfHN43KZpu+6eZDEOMHTfNYAkpmUWJNgtRQFIS6BTc7vH/DYQ==} + peerDependencies: + mermaid: ^11.0.2 + + '@mermaid-js/layout-tidy-tree@0.2.2': + resolution: {integrity: sha512-8RmjDXjKJBxqTS1mICStm8zWRM45fSzs0SOrkp28+KsOGS2YEMFMVTwwRU8CsC6M1L+pDYZVjf1m9AC1c9Wndg==} + peerDependencies: + mermaid: ^11.0.2 + + '@mermaid-js/mermaid-cli@11.15.0': + resolution: {integrity: sha512-rmz9ELKtmKQvRcYJGI2e509FK9yCBvmEVfHeRSYkleGqo6qqh8LFooxRPCqq04uVx3JHMp9g/vmM85gi/QFFlQ==} + engines: {node: ^18.19 || >=20.0} + hasBin: true + peerDependencies: + puppeteer: ^23 || ^24 + + '@mermaid-js/mermaid-zenuml@0.2.3': + resolution: {integrity: sha512-RGBtgL6fc+5Y2Jm9odOH9HRJ80BP4l6atBYnAK5bBzEowF0PU3UtvZRRcbFxImPGPuLIzqZq31ur8lVO0AoF3Q==} + peerDependencies: + mermaid: ^10 || ^11 + + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + + '@puppeteer/browsers@2.13.2': + resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} + engines: {node: '>=18'} + hasBin: true + + '@react-aria/focus@3.22.1': + resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.1': + resolution: {integrity: sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/react-virtual@3.14.4': + resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.2': + resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@zenuml/core@3.50.1': + resolution: {integrity: sha512-Q18BXvIfhRuGw0JfO4e+pltG04Phhv2E7n35EWTNSZKKH/HEkyE4Sh7KERb9dqaOfjOdRdKvLG0YekjK3fcDXw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + playwright-core: 1.57.0 + peerDependenciesMeta: + playwright-core: + optional: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + antlr4@4.11.0: + resolution: {integrity: sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.2: + resolution: {integrity: sha512-h530JsrkYi8518ZfR57GHaLoI5YzXkGGEV0Y+mf4KYPBn4OnNajiznwkDq7FgE+Vnmyss9Utnzi44y7sowiAXA==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.1: + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chromium-bidi@14.0.0: + resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} + peerDependencies: + devtools-protocol: '*' + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + devtools-protocol@0.0.1608973: + resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + jotai@2.20.1: + resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + puppeteer-core@24.43.1: + resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==} + engines: {node: '>=18'} + + puppeteer@24.43.1: + resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==} + engines: {node: '>=18'} + hasBin: true + + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + webdriver-bidi-protocol@0.4.1: + resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/react@0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.11': {} + + '@fortawesome/fontawesome-free@7.3.0': {} + + '@headlessui/react@2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/focus': 3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/interactions': 3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@headlessui/tailwindcss@0.2.2(tailwindcss@4.3.1)': + dependencies: + tailwindcss: 4.3.1 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.9': + dependencies: + '@swc/helpers': 0.5.23 + + '@mermaid-js/layout-elk@0.2.2(mermaid@11.16.0)': + dependencies: + d3: 7.9.0 + elkjs: 0.9.3 + mermaid: 11.16.0 + + '@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.16.0)': + dependencies: + d3: 7.9.0 + mermaid: 11.16.0 + optional: true + + '@mermaid-js/mermaid-cli@11.15.0(puppeteer@24.43.1)(tailwindcss@4.3.1)': + dependencies: + '@fortawesome/fontawesome-free': 7.3.0 + '@mermaid-js/layout-elk': 0.2.2(mermaid@11.16.0) + '@mermaid-js/mermaid-zenuml': 0.2.3(mermaid@11.16.0)(tailwindcss@4.3.1) + chalk: 5.6.2 + commander: 13.1.0 + import-meta-resolve: 4.2.0 + katex: 0.16.47 + mermaid: 11.16.0 + p-limit: 6.2.0 + puppeteer: 24.43.1 + optionalDependencies: + '@mermaid-js/layout-tidy-tree': 0.2.2(mermaid@11.16.0) + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - playwright-core + - tailwindcss + + '@mermaid-js/mermaid-zenuml@0.2.3(mermaid@11.16.0)(tailwindcss@4.3.1)': + dependencies: + '@zenuml/core': 3.50.1(tailwindcss@4.3.1) + mermaid: 11.16.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - playwright-core + - tailwindcss + + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + + '@puppeteer/browsers@2.13.2': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.8.5 + tar-fs: 3.1.3 + yargs: 17.7.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@react-aria/focus@3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-aria/interactions@3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-types/shared@3.36.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.2': {} + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/geojson@7946.0.16': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + optional: true + + '@types/trusted-types@2.0.7': + optional: true + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 26.0.1 + optional: true + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@zenuml/core@3.50.1(tailwindcss@4.3.1)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/react': 2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1) + antlr4: 4.11.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + color-string: 2.1.4 + dompurify: 3.4.11 + highlight.js: 11.11.1 + html-to-image: 1.11.13 + jotai: 2.20.1(react@19.2.7) + marked: 4.3.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tailwind-merge: 3.6.0 + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - tailwindcss + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + antlr4@4.11.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + b4a@1.8.1: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.2: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.2 + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + + basic-ftp@5.3.1: {} + + buffer-crc32@0.2.13: {} + + callsites@3.1.0: {} + + chalk@5.6.2: {} + + chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): + dependencies: + devtools-protocol: 0.0.1608973 + mitt: 3.0.1 + zod: 3.25.76 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + commander@13.1.0: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + cosmiconfig@9.0.2: + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + parse-json: 5.2.0 + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-uri-to-buffer@6.0.2: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + devtools-protocol@0.0.1608973: {} + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + elkjs@0.9.3: {} + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-toolkit@1.49.0: {} + + escalade@3.2.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-fifo@1.3.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + get-caller-file@2.0.5: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hachure-fill@0.5.2: {} + + highlight.js@11.11.1: {} + + html-to-image@1.11.13: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.2.0: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + jotai@2.20.1(react@19.2.7): + optionalDependencies: + react: 19.2.7 + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lines-and-columns@1.2.4: {} + + lodash-es@4.18.1: {} + + lru-cache@7.18.3: {} + + marked@16.4.2: {} + + marked@4.3.0: {} + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.49.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + mitt@3.0.1: {} + + ms@2.1.3: {} + + netmask@2.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-data-parser@0.1.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + progress@2.0.3: {} + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + puppeteer-core@24.43.1: + dependencies: + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) + debug: 4.4.3 + devtools-protocol: 0.0.1608973 + typed-query-selector: 2.12.2 + webdriver-bidi-protocol: 0.4.1 + ws: 8.21.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.43.1: + dependencies: + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) + cosmiconfig: 9.0.2 + devtools-protocol: 0.0.1608973 + puppeteer-core: 24.43.1 + typed-query-selector: 2.12.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + + react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.48.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-stately@3.48.0(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + + react@19.2.7: {} + + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + + robust-predicates@3.0.3: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + source-map@0.6.1: + optional: true + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylis@4.4.0: {} + + tabbable@6.5.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.1: {} + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.2 + bare-path: 3.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinyexec@1.2.4: {} + + ts-dedent@2.3.0: {} + + tslib@2.8.1: {} + + typed-query-selector@2.12.2: {} + + undici-types@8.3.0: + optional: true + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + uuid@14.0.1: {} + + webdriver-bidi-protocol@0.4.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@1.2.2: {} + + zod@3.25.76: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 0000000000..a66f92190a --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Allow @mermaid-js/mermaid-cli's puppeteer dependency to run its install +# script, which fetches the headless Chrome used to render diagrams. +allowBuilds: + puppeteer: true diff --git a/docs/project/code-style.md b/docs/project/code-style.md new file mode 100644 index 0000000000..8ce7748dbe --- /dev/null +++ b/docs/project/code-style.md @@ -0,0 +1,35 @@ +# Code Style + +## Formatting + +tmuxp uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. + +```console +$ uv run ruff format . +``` + +```console +$ uv run ruff check . --fix --show-fixes +``` + +## Type Checking + +Strict [mypy](https://mypy-lang.org/) is enforced. + +```console +$ uv run mypy +``` + +## Docstrings + +All public functions and methods use +[NumPy-style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html). + +## Imports + +- Standard library: namespace imports (`import pathlib`, not `from pathlib import Path`) + - Exception: `from dataclasses import dataclass, field` for + {func}`~dataclasses.dataclass` and {func}`~dataclasses.field` +- Typing: `import typing as t`, access via {data}`t.Optional `, + {class}`t.NamedTuple `, etc. +- All files: `from __future__ import annotations` diff --git a/docs/project/contributing.md b/docs/project/contributing.md new file mode 100644 index 0000000000..49214355bf --- /dev/null +++ b/docs/project/contributing.md @@ -0,0 +1,297 @@ +(developing)= + +# Developing and Testing + +The tests live in `tests/`, written with [pytest]. They run against a real tmux +server on a separate socket (`$ tmux -L test_case`), so they never disturb your +own sessions. + +[pytest]: http://pytest.org/ + +(install-dev-env)= + +## Install the latest code from git + +### Get the source + +Check out the code from GitHub: + +```console +$ git clone git@github.com:tmux-python/tmuxp.git +``` + +```console +$ cd tmuxp +``` + +### Bootstrap + +The easiest way to set up a dev environment is with [uv], which manages the +virtualenv and Python dependencies for you. (See [uv's documentation] to install +uv itself.) + +Create the virtualenv and install everything locked in `uv.lock`: + +```console +$ uv sync --all-extras --dev +``` + +To refresh those packages later: + +```console +$ uv sync --all-extras --dev --upgrade +``` + +Then prefix any Python command with `uv run`: + +```console +$ uv run [command] +``` + +That's it — you're ready to code. + +[uv]: https://github.com/astral-sh/uv +[uv's documentation]: https://docs.astral.sh/uv + +### Advanced: manual virtualenv + +Prefer to manage the virtualenv yourself? Create one: + +```console +$ virtualenv .venv +``` + +Activate it in your current shell: + +```console +$ source .venv/bin/activate +``` + +Install tmuxp in editable mode, so your edits take effect immediately: + +```console +$ pip install -e . +``` + +With a uv-managed project, add the checkout as an editable dev dependency +instead: + +```console +$ uv add --dev --editable . +``` + +Prefer a one-off, pipx-style run while you hack? Call tmuxp through [uvx]: + +```console +$ uvx tmuxp +``` + +[uvx]: https://docs.astral.sh/uv/guides/tools/ + +## Test runner + +[pytest] runs the tests. Inside the virtualenv, the `tmuxp` command and a +project-local `python` are already on your `PATH`. + +### Rerun on file change + +Watch files and re-run tests on every save, via [pytest-watcher]: + +```console +$ just start +``` + +[pytest-watcher]: https://github.com/olzhasar/pytest-watcher + +### Manual + +```console +$ uv run py.test +``` + +Or: + +```console +$ just test +``` + +### pytest options + +Pass extra arguments through `PYTEST_ADDOPTS`. See the [pytest usage docs] for +everything it accepts. + +[pytest usage docs]: https://docs.pytest.org/ + +Verbose: + +```console +$ env PYTEST_ADDOPTS="--verbose" just start +``` + +Pick a file: + +```console +$ env PYTEST_ADDOPTS="tests/workspace/test_builder.py" just start +``` + +Drop into a single test and stop on the first error: + +```console +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" \ + just start +``` + +Drop into `pdb` on the first error: + +```console +$ env PYTEST_ADDOPTS="-x -s --pdb" just start +``` + +With [ipython] installed: + +```console +$ env PYTEST_ADDOPTS="--pdbcls=IPython.terminal.debugger:TerminalPdb" just start +``` + +[ipython]: https://ipython.org/ + +(test-specific-tests)= + +### Manual invocation + +Test a single file: + +```console +$ py.test tests/test_config.py +``` + +A single test inside it: + +```console +$ py.test tests/test_config.py::test_export_json +``` + +Several at once, space-separated: + +```console +$ py.test tests/test_{window,pane}.py tests/test_config.py::test_export_json +``` + +(test-builder-visually)= + +### Visual testing + +You can watch the suite build sessions in real time by keeping a client open in +a second terminal. + +Terminal 1 — start a server on the test socket: + +```console +$ tmux -L test_case +``` + +Terminal 2 — from the tmuxp checkout (and your virtualenv, if you use one), run +the builder tests: + +```console +$ py.test tests/workspace/test_builder.py +``` + +Terminal 1 flickers as sessions build before your eyes — the building tmuxp +normally hides from users. + +### Testing options + +Set `RETRY_TIMEOUT_SECONDS` if certain workspace-builder tests are stubborn on +your machine, e.g. `RETRY_TIMEOUT_SECONDS=10 py.test`. CI runs the same suite: + +```{literalinclude} ../../.github/workflows/tests.yml +:language: yaml +``` + +## Documentation + +Rebuild the docs whenever a source file changes: + +```console +$ just watch-docs +``` + +(tmuxp-developer-config)= + +## tmuxp developer config + +```{image} /_static/tmuxp-dev-screenshot.png +:width: 1030 +:height: 605 +:align: center +:loading: lazy +``` + +After you {ref}`install-dev-env`, load the project's own workspace from the +checkout root: + +```console +$ tmuxp load . +``` + +This loads the `.tmuxp.yaml` at the project root: + +```{literalinclude} ../../.tmuxp.yaml +:language: yaml +``` + +## Formatting + +### Linting + +The project uses [ruff] for linting, import sorting, and formatting. + +Lint: + +```console +$ just ruff +``` + +Autofix what ruff can: + +```console +$ uv run ruff check . --fix --show-fixes +``` + +#### Formatting + +[ruff format] handles formatting: + +```console +$ just ruff-format +``` + +### Type checking + +[mypy] does static type checking: + +```console +$ just mypy +``` + +Re-check on change: + +```console +$ just watch-mypy +``` + +(gh-actions)= + +## Continuous integration + +tmuxp uses [GitHub Actions] for continuous integration. To see the tmux and +Python versions under test, read [.github/workflows/tests.yml]. Builds run on +`master` and on pull requests, and are visible on the [build site]. + +[ruff]: https://ruff.rs +[ruff format]: https://docs.astral.sh/ruff/formatter/ +[mypy]: http://mypy-lang.org/ +[GitHub Actions]: https://github.com/features/actions +[build site]: https://github.com/tmux-python/tmuxp/actions?query=workflow%3Atests +[.github/workflows/tests.yml]: https://github.com/tmux-python/tmuxp/blob/master/.github/workflows/tests.yml diff --git a/docs/project/index.md b/docs/project/index.md new file mode 100644 index 0000000000..f3360dc2e2 --- /dev/null +++ b/docs/project/index.md @@ -0,0 +1,38 @@ +(project)= + +# Project + +Information for contributors and maintainers. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Contributing +:link: contributing +:link-type: doc +Development setup, running tests, submitting PRs. +::: + +:::{grid-item-card} Code Style +:link: code-style +:link-type: doc +[Ruff](https://ruff.rs), [mypy](https://mypy-lang.org/), +[NumPy-style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html), +import conventions. +::: + +:::{grid-item-card} Releasing +:link: releasing +:link-type: doc +Release checklist and version policy. +::: + +:::: + +```{toctree} +:hidden: + +contributing +code-style +releasing +``` diff --git a/docs/project/releasing.md b/docs/project/releasing.md new file mode 100644 index 0000000000..9460f03027 --- /dev/null +++ b/docs/project/releasing.md @@ -0,0 +1,57 @@ +# Releasing + +## Release process + +You release tmuxp by tagging a version: pushing a `v` tag triggers a CI +workflow that builds the package and publishes it to [PyPI] via +[OIDC trusted publishing]. + +1. Update `CHANGES` with the release notes. +2. Bump the version in `src/tmuxp/__about__.py`. +3. Commit the bump: + + ```console + $ git commit -m "Tag v" + ``` + +4. Tag it: + + ```console + $ git tag v + ``` + +5. Push the commit and the tag: + + ```console + $ git push && git push --tags + ``` + +6. CI builds and publishes to [PyPI] automatically. + +## Changelog format + +`CHANGES` is rendered as the changelog page. Each release is a Markdown section +headed by its version and date: + +```text +## tmuxp () + +### Breaking changes + +- Description of the break, with a migration path (#issue) + +### What's new + +- Description of the feature (#issue) + +### Fixes + +- Description of the fix (#issue) +``` + +Subheadings appear in a fixed order when present: `### Breaking changes`, +`### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, +`### Development`. + +[PyPI]: https://pypi.org/project/tmuxp/ +[OIDC trusted publishing]: https://docs.pypi.org/trusted-publishers/ diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000000..f296445e43 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,189 @@ +(quickstart)= + +# Quickstart + +tmuxp launches a whole tmux workspace — its windows, panes, and the commands +inside them — from a single YAML or JSON file. Install it, write one file, and +{ref}`tmuxp load ` builds the session and drops you into it. This page +takes you from nothing to a running session. + +## Installation + +Ensure you have at least tmux **>= 3.2** and python **>= 3.10**. + +```console +$ pip install --user tmuxp +``` + +If you manage dependencies with [uv] inside a project environment, add tmuxp to +your lockfile instead: + +```console +$ uv add tmuxp +``` + +To run tmuxp without installing it globally — the way you'd use [pipx] — invoke +it through [uvx]: + +```console +$ uvx tmuxp +``` + +Upgrade to the latest release with: + +```console +$ pip install --user --upgrade tmuxp +``` + +Within a uv-managed project, upgrade by refreshing the lockfile and syncing: + +```console +$ uv lock --upgrade-package tmuxp +``` + +```console +$ uv sync +``` + +Then install {ref}`completion`. + +Homebrew users can install it with: + +```console +$ brew install tmuxp +``` + +(developmental-releases)= + +### Developmental releases + +New versions of tmuxp are published to [PyPI] as alpha, beta, or release +candidates. Their version carries an `a1`, `b1`, or `rc1` suffix — `1.10.0b4` is +the fourth beta of `1.10.0`, before general availability. + +Install the latest pre-release with the tool you use: + +```console +$ pip install --user --upgrade --pre tmuxp +``` + +```console +$ uv add tmuxp --prerelease allow +``` + +```console +$ uvx --from 'tmuxp' --prerelease allow tmuxp +``` + +```console +$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +``` + +After the pipx install, load with `tmuxp@next load [session]`. + +Or track trunk directly (it can break): + +```console +$ pip install --user -e git+https://github.com/tmux-python/tmuxp.git#egg=tmuxp +``` + +```console +$ uv add "tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master" +``` + +```console +$ uvx --from "tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master" tmuxp +``` + +```console +$ pipx install --suffix=@master 'tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master' --force +``` + +[pip]: https://pip.pypa.io/en/stable/ +[pipx]: https://pypa.github.io/pipx/docs/ +[PyPI]: https://pypi.org/project/tmuxp/ +[uv]: https://docs.astral.sh/uv/getting-started/features/#python-versions +[uvx]: https://docs.astral.sh/uv/guides/tools/ + +## Commands + +:::{seealso} + +{ref}`examples`, {ref}`commands`, {ref}`completion`. + +::: + +tmuxp launches workspaces / sessions from JSON and YAML files. + +Workspace files live in `$HOME/.tmuxp`, or in a project directory as +`.tmuxp.yaml`, `.tmuxp.yml`, or `.tmuxp.json`. Every workspace file needs: + +1. a `session_name` +2. a list of `windows` +3. a list of `panes` for every window + +Create a file, `~/.tmuxp/example.yaml`: + +```{literalinclude} ../examples/2-pane-vertical.yaml +:language: yaml +``` + +```console +$ tmuxp load example.yaml +``` + +This builds and attaches your session. + +Load several at once: + +```console +$ tmuxp load example.yaml anothersession.yaml +``` + +If you're already inside a session, tmuxp offers to `switch-client` for you, or +to append the new windows to the session you're in. + +You can point tmuxp at a different config directory with the `TMUXP_CONFIGDIR` +environment variable: + +```console +$ TMUXP_CONFIGDIR=$HOME/.tmuxpmoo tmuxp load cpython +``` + +Or set it in your `~/.bashrc` / `~/.zshrc`: + +```console +$ export TMUXP_CONFIGDIR=$HOME/.yourconfigdir/tmuxp +``` + +You can also [import][import] configs from [teamocil] and [tmuxinator]. + +## Pythonics + +:::{seealso} + +{ref}`libtmux python API documentation ` and {ref}`developing`. + +::: + +Under the hood, tmuxp drives tmux through +[libtmux](https://libtmux.git-pull.com/) — an +[object-relational mapper][object relational mapper] and +[abstraction layer] over `tmux(1)`'s commands. Each config concept maps to a +libtmux call: + +| {ref}`libtmux Python API ` | {term}`tmux(1)` equivalent | +| ------------------------------------- | -------------------------- | +| {meth}`libtmux.Server.new_session` | `$ tmux new-session` | +| {attr}`libtmux.Server.sessions` | `$ tmux list-sessions` | +| {attr}`libtmux.Session.windows` | `$ tmux list-windows` | +| {meth}`libtmux.Session.new_window` | `$ tmux new-window` | +| {attr}`libtmux.Window.panes` | `$ tmux list-panes` | +| {meth}`libtmux.Window.split` | `$ tmux split-window` | +| {meth}`libtmux.Pane.send_keys` | `$ tmux send-keys` | + +[import]: http://tmuxp.git-pull.com/commands/#import +[tmuxinator]: https://github.com/aziz/tmuxinator +[teamocil]: https://github.com/remiprev/teamocil +[abstraction layer]: http://en.wikipedia.org/wiki/Abstraction_layer +[object relational mapper]: http://en.wikipedia.org/wiki/Object-relational_mapping diff --git a/docs/redirects.txt b/docs/redirects.txt new file mode 100644 index 0000000000..53b041de9d --- /dev/null +++ b/docs/redirects.txt @@ -0,0 +1,40 @@ +# "cli.md" → commands/index.md → cli/index.md: not needed with dirhtml (same output path) +# "api.md" → api/index.md: not needed with dirhtml (same output path as api/index.md redirect) +"examples.md" "configuration/examples.md" +"plugin_system.md" "plugins/index.md" +"commands/index.md" "cli/index.md" +"api/index.md" "internals/api/index.md" +"api/exc.md" "internals/api/exc.md" +"api/log.md" "internals/api/log.md" +"api/plugin.md" "internals/api/plugin.md" +"api/shell.md" "internals/api/shell.md" +"api/types.md" "internals/api/types.md" +"api/util.md" "internals/api/util.md" +"api/cli/index.md" "internals/api/cli/index.md" +"api/cli/convert.md" "internals/api/cli/convert.md" +"api/cli/debug_info.md" "internals/api/cli/debug_info.md" +"api/cli/edit.md" "internals/api/cli/edit.md" +"api/cli/freeze.md" "internals/api/cli/freeze.md" +"api/cli/import_config.md" "internals/api/cli/import_config.md" +"api/cli/load.md" "internals/api/cli/load.md" +"api/cli/ls.md" "internals/api/cli/ls.md" +"api/cli/progress.md" "internals/api/cli/progress.md" +"api/cli/search.md" "internals/api/cli/search.md" +"api/cli/shell.md" "internals/api/cli/shell.md" +"api/cli/utils.md" "internals/api/cli/utils.md" +"api/workspace/index.md" "internals/api/workspace/index.md" +"api/workspace/builder.md" "internals/api/workspace/builder/index.md" +"api/workspace/constants.md" "internals/api/workspace/constants.md" +"api/workspace/finders.md" "internals/api/workspace/finders.md" +"api/workspace/freezer.md" "internals/api/workspace/freezer.md" +"api/workspace/importers.md" "internals/api/workspace/importers.md" +"api/workspace/loader.md" "internals/api/workspace/loader.md" +"api/workspace/validation.md" "internals/api/workspace/validation.md" +"api/internals/index.md" "internals/api/_internal/index.md" +"api/internals/colors.md" "internals/api/_internal/colors.md" +"api/internals/config_reader.md" "internals/api/_internal/config_reader.md" +"api/internals/private_path.md" "internals/api/_internal/private_path.md" +"api/internals/types.md" "internals/api/_internal/types.md" +"plugins/index.md" "topics/plugins.md" +"developing.md" "project/contributing.md" +"about.md" "topics/index.md" diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md new file mode 100644 index 0000000000..4fbb4422b6 --- /dev/null +++ b/docs/topics/custom-workspace-builders.md @@ -0,0 +1,200 @@ +(custom-workspace-builders)= + +# Custom workspace builders + +A *workspace builder* turns an expanded workspace {class}`dict` into a live tmux +session. tmuxp ships one builder, +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, and uses it +by default. You can point tmuxp at a different builder, and packagers can +distribute builders that you select by name. + +This is an advanced, opt-in feature. Existing workspace files keep using the +classic builder with no changes. + +```{seealso} +If you only want to set these keys in a workspace file, see +{ref}`workspace-builders` in the configuration reference. +``` + +## How a workspace is built + +1. {ref}`tmuxp load ` reads the YAML/JSON file and expands it + (shorthand, environment variables, trickle-down defaults). +2. tmuxp resolves which builder to use from the + {ref}`workspace_builder ` config key (default: the + classic builder). +3. tmuxp constructs the builder with the expanded workspace and a + {class}`libtmux.Server`, then calls + {meth}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol.build`. +4. The builder creates the session, windows, and panes, honoring plugin hooks + and progress callbacks. + +## Selecting a builder + +### By dotted path + +Point `workspace_builder` at an importable class. Both a +`module:attr` object reference and a dotted `module.attr` path are accepted: + +```yaml +session_name: my-session +workspace_builder: my_tmuxp_builders.builders:CustomBuilder +windows: + - window_name: editor + panes: + - vim +``` + +### By entry-point name + +Packaged builders register under the `tmuxp.workspace_builders` entry-point +group, letting users select them by a short name instead of an internal module +path: + +```yaml +workspace_builder: classic +``` + +The built-in classic builder is registered this way. A distribution registers +its own builder in `pyproject.toml`: + +```toml +[project.entry-points."tmuxp.workspace_builders"] +mybuilder = "my_tmuxp_builders.builders:CustomBuilder" +``` + +### Trusted import paths + +When a builder lives outside tmuxp's runtime environment (for example, a script +in your config directory), list trusted directories in +{ref}`workspace_builder_paths `. tmuxp expands `~` +and environment variables, +resolves relative entries against the workspace file's directory, requires each +entry to be an existing directory, and temporarily prepends them to {data}`sys.path` +for the import and build: + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +:::{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. tmuxp deliberately does **not** use +{func}`site.addsitedir` for these paths — that would execute `.pth` startup files +and is broader than making a module importable. +::: + +## Writing a builder + +The simplest custom builder subclasses the classic builder and overrides what it +needs: + +```python +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """A builder that renames the session after building.""" + + def build(self, session=None, append=False): + super().build(session=session, append=append) + self.session.rename_session(f"{self.session.name}-custom") +``` + +A builder written from scratch must satisfy +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. The contract +covers what `tmuxp load` drives: + +- **Constructor** accepting `session_config`, `server`, and the optional + `plugins` list and `on_progress` / `on_before_script` / `on_script_output` / + `on_build_event` callbacks. +- **{meth}`build(session=None, append=False) `** + — create or populate the session; the `append` path adds windows to an + existing session. +- **{attr}`session `** + — the populated {class}`libtmux.Session`. +- **{meth}`session_exists() `** + and + **{meth}`find_current_attached_session() `** + — used by the CLI for attach/append decisions. +- **`plugins`** — the list of plugin instances; honor the plugin lifecycle hooks + ({meth}`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder`, + {meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create`, + {meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished`). +- The **`on_*` callbacks** — call them at the documented milestones so the CLI's + progress display and `before_script` output stay accurate. + +The contract is synchronous today. It is shaped so an async builder can be added +later as an additive extension without changing this surface. + +## Pane readiness + +tmuxp waits for a pane's shell prompt before dispatching layout and commands, +which avoids a zsh prompt-redraw artifact. That wait is only needed for zsh, so +it is configurable independently of which builder you use, through the +{ref}`workspace_builder_options ` catalog: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +``` + +- **`auto`** (default) — wait only when the session's interactive shell is zsh. +- **`always`** (or `true`/`on`/`yes`/`1`) — always wait for default-shell panes. +- **`never`** (or `false`/`off`/`no`/`0`) — never wait; fastest, but accepts the + prompt/layout race for shells that need it. + +Panes with a custom `shell` or `window_shell` never wait, regardless of policy — +those run a command in place of an interactive shell, so there is no prompt to +wait for. + +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Testing a builder + +Resolve, construct, and build against a real tmux server fixture: + +```python +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry + + +def test_custom_builder(server): + config = loader.expand( + { + "session_name": "demo", + "workspace_builder": "my_tmuxp_builders.builders:CustomBuilder", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "demo" + builder.session.kill() +``` + +For builders that live in a trusted directory, build the {data}`sys.path` sandbox with +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths` and +{func}`~tmuxp.workspace.builder.registry.prepended_sys_path`. + +## Choosing an approach + +- **Classic builder** — the default. Use it for any workspace that depends on + strict, pane-by-pane side effects (`start_directory`, `shell`, `window_shell`, + pane environment). +- **Readiness tuning** — set `pane_readiness` to trade prompt-safety for speed + without swapping builders. +- **A custom builder** — when you need behavior the classic builder doesn't + provide. Keep dependency-sensitive setup in `before_script` or + `shell_command_before` if your builder relaxes ordering guarantees. + +## Reference + +- {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` +- {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +- {func}`~tmuxp.workspace.builder.registry.resolve_builder_class` +- {class}`~tmuxp.workspace.options.PaneReadiness` diff --git a/docs/topics/index.md b/docs/topics/index.md new file mode 100644 index 0000000000..52c8cacb81 --- /dev/null +++ b/docs/topics/index.md @@ -0,0 +1,59 @@ +(topics)= + +# Topics + +Conceptual guides and workflow documentation. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Workflows +:link: workflows +:link-type: doc +CI integration, scripting, and automation patterns. +::: + +:::{grid-item-card} Plugins +:link: plugins +:link-type: doc +Plugin system for custom behavior. +::: + +:::{grid-item-card} Custom workspace builders +:link: custom-workspace-builders +:link-type: doc +Select or ship a custom builder; tune pane readiness. +::: + +:::{grid-item-card} Library vs CLI +:link: library-vs-cli +:link-type: doc +When to use tmuxp CLI vs [libtmux](https://libtmux.git-pull.com/) directly. +::: + +:::{grid-item-card} Troubleshooting +:link: troubleshooting +:link-type: doc +Common shell, PATH, and tmux issues. +::: + +:::: + +## Compared to other session managers + +tmuxp, [tmuxinator](https://github.com/aziz/tmuxinator), and +[teamocil](https://github.com/remiprev/teamocil) all load tmux sessions +from config files. Key differences: tmuxp is Python (not Ruby), builds +sessions through [libtmux](https://libtmux.git-pull.com/)'s ORM layer +instead of raw shell commands, supports JSON and YAML, and can +[freeze](../cli/freeze.md) running sessions back to config. + +```{toctree} +:hidden: + +workflows +plugins +custom-workspace-builders +library-vs-cli +troubleshooting +``` diff --git a/docs/topics/library-vs-cli.md b/docs/topics/library-vs-cli.md new file mode 100644 index 0000000000..d0da75531d --- /dev/null +++ b/docs/topics/library-vs-cli.md @@ -0,0 +1,67 @@ +# Library vs CLI + +tmuxp is a CLI tool. [libtmux](https://libtmux.git-pull.com/) is the Python library it's built on. Both control tmux, but they serve different needs. + +## When to Use the CLI + +Use `tmuxp` when: + +- You want **declarative workspace configs** — define your layout in YAML, load it with one command +- You're setting up **daily development environments** — same windows, same panes, every time +- You need **CI/CD tmux sessions** — run {ref}`tmuxp load -d ` in a + script +- You prefer **configuration over code** — no Python needed + +```console +$ tmuxp load my-workspace.yaml +``` + +New to the format? Start with {ref}`quickstart`, then browse ready-to-load files +in {ref}`examples`. + +## When to Use libtmux + +Use libtmux directly when: + +- You need **dynamic logic** — conditionals, loops, branching based on state +- You want to **read pane output** — capture what's on screen and react to it +- You're **testing** tmux interactions — libtmux provides + [pytest](https://docs.pytest.org/) fixtures +- You need **multi-server orchestration** — manage multiple tmux servers programmatically +- The CLI's config format **can't express** what you need + +```python +import libtmux + +server = libtmux.Server() +session = server.new_session("my-project") +window = session.new_window("editor") +pane = window.split() +pane.send_keys("vim .") +``` + +## Concept Mapping + +How tmuxp config keys map to libtmux API calls: + +| tmuxp YAML | libtmux equivalent | +|------------|-------------------| +| `session_name: foo` | {meth}`server.new_session(session_name="foo") ` | +| `windows:` | {meth}`session.new_window(...) ` | +| `panes:` | {meth}`window.split(...) ` | +| `shell_command:` | {meth}`pane.send_keys(...) ` | +| `layout: main-vertical` | {meth}`window.select_layout("main-vertical") ` | +| `start_directory: ~/project` | {meth}`session.new_window(start_directory="~/project") ` | +| `before_script:` | Run via {mod}`subprocess` before building | + +## What the CLI Can't Express + +tmuxp configs are static declarations. They can't: + +- **Branch on conditions** — "only create this pane if a file exists" +- **Read pane output** — "wait until the server is ready, then open the browser" +- **React to state** — "if this session already has 3 windows, add a 4th" +- **Orchestrate across servers** — "connect to both local and remote tmux" +- **Build layouts dynamically** — "create N panes based on a list of services" + +For these, use libtmux directly. See the {ref}`libtmux quickstart `. diff --git a/docs/topics/plugins.md b/docs/topics/plugins.md new file mode 100644 index 0000000000..d664cb633f --- /dev/null +++ b/docs/topics/plugins.md @@ -0,0 +1,172 @@ +(plugins)= + +# Plugins + +Plugins let you customize and extend how tmuxp builds a session — renaming it, +reacting when you reattach, running setup at specific moments — without forking +tmuxp or hand-editing your workspace files. This is an advanced, opt-in feature: +most users never write or install one, and a workspace loads exactly the same +with no plugins at all. Reach for a plugin when you want behavior the workspace +format can't express and you're comfortable writing a little Python. + +## Using a plugin + +Install the plugin into the same Python environment as tmuxp, then name it in +your workspace file under `plugins`: + +````{tab} YAML + +```{literalinclude} ../../examples/plugin-system.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/plugin-system.json +:language: json + +``` + +```` + +## When your hooks fire + +A plugin is a class whose methods tmuxp calls at set points while it builds and +attaches the session. You override only the hooks you care about; the rest do +nothing. {ref}`tmuxp load ` drives the lifecycle in a fixed order: + +:::{mermaid} +:caption: When each plugin hook fires. +:alt: tmuxp plugin hook order during tmuxp load +:name: tmuxp-plugin-hook-order +:responsive: fit + +flowchart TD + load["tmuxp load"]:::cmd --> bwb["before_workspace_builder"]:::cmd + bwb --> oc["on_window_create"]:::cmd + oc --> panes["create panes, run commands"] + panes --> awf["after_window_finished"]:::cmd + awf -->|more windows| oc + awf -->|all windows built| bs["before_script"]:::cmd + bs --> reattach["reattach"]:::cmd +::: + +{meth}`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder` runs first, once +the session exists but before any windows. +{meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create` and +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` bracket each window's +panes. Two of the names can mislead: +{meth}`~tmuxp.plugin.TmuxpPlugin.before_script` runs _after_ the whole session +is built — it augments, rather than replaces, the workspace's own +`before_script` — and {meth}`~tmuxp.plugin.TmuxpPlugin.reattach` fires only +when tmuxp re-attaches you to a session that already exists. + +## Developing a plugin + +tmuxp expects a plugin to be a class in a Python submodule named `plugin`, inside +a module installed in the same environment as tmuxp. You inherit from the +interface tmuxp provides, {class}`~tmuxp.plugin.TmuxpPlugin`. + +[uv] is tmuxp's package manager of choice, and what these examples use; `pip` +works just as well. You need only one project file, for whichever packaging tool +you choose. + +```tree +python_module +├── tmuxp_plugin_my_plugin_module +│ ├── __init__.py +│ └── plugin.py +└── pyproject.toml # Python project configuration file +``` + +When publishing to [PyPI], tmuxp suggests the naming convention +`tmuxp-plugin-{your-plugin-name}` so others can find it. A minimal +`pyproject.toml` looks like this: + +```toml +[project] +name = "tmuxp-plugin-my-tmuxp-plugin" +version = "0.0.2" +description = "An example tmuxp plugin." +authors = [{ name = "Author Name", email = "author.name@example.com" }] +requires-python = ">=3.10" +dependencies = [ + "tmuxp>=1.7.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +The `plugin.py` file holds the class: + +```python +import datetime + +from tmuxp.plugin import TmuxpPlugin + + +class MyTmuxpPlugin(TmuxpPlugin): + def __init__(self): + """Initialize my custom plugin.""" + # Optional version-dependency configuration. See the Plugin API + # docs for every supported parameter. + config = { + 'tmuxp_min_version': '1.6.2', + } + + TmuxpPlugin.__init__( + self, + plugin_name='tmuxp-plugin-my-tmuxp-plugin', + **config, + ) + + def before_workspace_builder(self, session): + session.rename_session('my-new-session-name') + + def reattach(self, session): + now = datetime.datetime.now().strftime('%Y-%m-%d') + session.rename_session('session_{}'.format(now)) +``` + +Once it's installed in the same environment, name it in a workspace file: + +```yaml +session_name: plugin example +plugins: + - my_plugin_module.plugin.MyTmuxpPlugin +# ... the rest of your workspace +``` + +## Plugin API + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.__init__ +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.before_workspace_builder +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.on_window_create +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.after_window_finished +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.before_script +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.reattach +``` + +[uv]: https://github.com/astral-sh/uv +[PyPI]: https://pypi.org/ diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md new file mode 100644 index 0000000000..60f231a8f4 --- /dev/null +++ b/docs/topics/troubleshooting.md @@ -0,0 +1,41 @@ +# Troubleshooting + +## tmuxp command not found + +Ensure tmuxp is installed and on your `PATH`: + +```console +$ which tmuxp +``` + +If installed with `pip install --user`, ensure `~/.local/bin` is in your `PATH`. + +## tmux server not found + +tmuxp requires a running tmux server or will start one automatically. +Ensure tmux is installed: + +```console +$ tmux -V +``` + +Minimum required version: tmux 3.2. + +## Configuration errors + +Use {ref}`tmuxp debug-info ` to collect system information for +bug reports: + +```console +$ tmuxp debug-info +``` + +## Session already exists + +If a session with the same name already exists, tmuxp will prompt you. +Use {ref}`tmuxp load -d ` to load in detached mode alongside existing +sessions. + +## Shell completion not working + +See {ref}`completion` for setup instructions for bash, zsh, and tcsh. diff --git a/docs/topics/workflows.md b/docs/topics/workflows.md new file mode 100644 index 0000000000..2f8108d7cc --- /dev/null +++ b/docs/topics/workflows.md @@ -0,0 +1,58 @@ +# Workflows + +tmuxp is small enough to drop into a script, a CI job, or your daily startup +routine. This page collects a few patterns — running headless, branching on exit +codes, and turning a session you arranged by hand into a reusable file. None of +it is special machinery; it's the same {ref}`tmuxp load `, +{ref}`tmuxp freeze `, and {ref}`exit codes ` you +already have. + +## CI integration + +You can build a tmux session inside a CI pipeline for integration testing. Load +it detached so nothing waits on a terminal: + +```console +$ tmuxp load -d my-workspace.yaml +``` + +The `-d` flag loads the session in the background, which is what you want in a +headless environment. + +## Scripting + +tmuxp returns meaningful exit codes, so a script can tell success from failure +and branch on it. See {ref}`cli-exit-codes` for the full list. + +## Automating development environments + +You don't have to write a workspace file from scratch. Arrange a session the way +you like it, freeze it to capture the layout, then edit and replay it anywhere: + +:::{mermaid} +:caption: Capture a session once, replay it anywhere. +:alt: manual tmux session captured with tmuxp freeze and replayed with tmuxp load +:name: tmuxp-freeze-load-workflow +:responsive: fit + +flowchart TD + arrange["arrange tmux by hand"] --> freeze["tmuxp freeze"]:::cmd + freeze --> yaml["workspace.yaml"]:::cmd + yaml --> edit["edit + commit"] + edit --> load["tmuxp load"]:::cmd + load --> arrange +::: + +1. Arrange your ideal tmux layout by hand. +2. Freeze it: `tmuxp freeze my-session`. +3. Edit the generated YAML to add commands. +4. Load it on any machine: `tmuxp load my-workspace.yaml`. + +## User-level configuration + +You can store workspace files in any of these, then load them by name from +anywhere: + +- `~/.tmuxp/` (legacy) +- `~/.config/tmuxp/` (XDG default) +- a project-local `.tmuxp.yaml` or `.tmuxp/` directory diff --git a/examples/2-pane-synchronized.json b/examples/2-pane-synchronized.json new file mode 100644 index 0000000000..6cd349ebbf --- /dev/null +++ b/examples/2-pane-synchronized.json @@ -0,0 +1,15 @@ +{ + "session_name": "2-pane-synchronized", + "windows": [ + { + "window_name": "Two synchronized panes", + "panes": [ + "ssh server1", + "ssh server2" + ], + "options_after": { + "synchronize-panes": true + } + } + ] +} \ No newline at end of file diff --git a/examples/2-pane-synchronized.yaml b/examples/2-pane-synchronized.yaml new file mode 100644 index 0000000000..50f77473d6 --- /dev/null +++ b/examples/2-pane-synchronized.yaml @@ -0,0 +1,8 @@ +session_name: 2-pane-synchronized +windows: + - window_name: Two synchronized panes + panes: + - ssh server1 + - ssh server2 + options_after: + synchronize-panes: on diff --git a/examples/3-pane.yaml b/examples/3-pane.yaml index c298ebba66..76e6110344 100644 --- a/examples/3-pane.yaml +++ b/examples/3-pane.yaml @@ -1,12 +1,12 @@ session_name: 3-panes windows: -- window_name: dev window - layout: main-vertical - shell_command_before: - - cd ~/ - panes: - - shell_command: - - cd /var/log - - ls -al | grep \.log - - echo hello - - echo hello + - window_name: dev window + layout: main-vertical + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello diff --git a/examples/4-pane.yaml b/examples/4-pane.yaml index 04242fc5e1..d42b89955c 100644 --- a/examples/4-pane.yaml +++ b/examples/4-pane.yaml @@ -1,13 +1,13 @@ session_name: 4-pane-split windows: -- window_name: dev window - layout: tiled - shell_command_before: - - cd ~/ - panes: - - shell_command: - - cd /var/log - - ls -al | grep \.log - - echo hello - - echo hello - - echo hello + - window_name: dev window + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/examples/blank-panes.yaml b/examples/blank-panes.yaml index 64a930de7f..9828ac1874 100644 --- a/examples/blank-panes.yaml +++ b/examples/blank-panes.yaml @@ -4,24 +4,24 @@ windows: # All these are equivalent - window_name: Blank pane test panes: - - - - pane - - blank + - + - pane + - blank - window_name: More blank panes panes: - - null - - shell_command: - - shell_command: - - + - null + - shell_command: + - shell_command: + - # an empty string will be treated as a carriage return - window_name: Empty string (return) panes: - - '' - - shell_command: '' - - shell_command: - - '' + - "" + - shell_command: "" + - shell_command: + - "" # a pane can have other options but still be blank - window_name: Blank with options panes: - - focus: true - - start_directory: /tmp + - focus: true + - start_directory: /tmp diff --git a/examples/env-variables.yaml b/examples/env-variables.yaml index d2c584be90..e448f5a0cc 100644 --- a/examples/env-variables.yaml +++ b/examples/env-variables.yaml @@ -3,15 +3,15 @@ shell_command_before: "echo ${PWD}" before_script: "${MY_ENV_VAR}/test3.sh" session_name: session - ${USER} (${MY_ENV_VAR}) windows: -- window_name: editor - panes: - - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log -- window_name: logging for ${USER} - options: - automatic-rename: true - panes: - - shell_command: - - htop - - ls $PWD + - window_name: editor + panes: + - shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging for ${USER} + options: + automatic-rename: true + panes: + - shell_command: + - htop + - ls $PWD diff --git a/examples/focus-window-and-panes.yaml b/examples/focus-window-and-panes.yaml index 0d2d762721..de4805660d 100644 --- a/examples/focus-window-and-panes.yaml +++ b/examples/focus-window-and-panes.yaml @@ -3,18 +3,18 @@ windows: - window_name: attached window focus: true panes: - - shell_command: - - echo hello - - echo 'this pane should be selected on load' - focus: true - - shell_command: - - cd /var/log - - echo hello + - shell_command: + - echo hello + - echo 'this pane should be selected on load' + focus: true + - shell_command: + - cd /var/log + - echo hello - window_name: second window shell_command_before: cd /var/log panes: - - pane - - shell_command: - - echo 'this pane should be focused, when window switched to first time' - focus: true - - pane + - pane + - shell_command: + - echo 'this pane should be focused, when window switched to first time' + focus: true + - pane diff --git a/examples/main-pane-height-percentage.json b/examples/main-pane-height-percentage.json new file mode 100644 index 0000000000..32b593d0ef --- /dev/null +++ b/examples/main-pane-height-percentage.json @@ -0,0 +1,31 @@ +{ + "windows": [ + { + "panes": [ + { + "shell_command": [ + "top" + ], + "start_directory": "~" + }, + { + "shell_command": [ + "echo \"hey\"" + ] + }, + { + "shell_command": [ + "echo \"moo\"" + ] + } + ], + "layout": "main-horizontal", + "options": { + "main-pane-height": "67%" + }, + "window_name": "editor" + } + ], + "session_name": "main pane height", + "start_directory": "~" +} diff --git a/examples/main-pane-height-percentage.yaml b/examples/main-pane-height-percentage.yaml new file mode 100644 index 0000000000..061aad4ab6 --- /dev/null +++ b/examples/main-pane-height-percentage.yaml @@ -0,0 +1,15 @@ +session_name: main-pane-height +start_directory: "~" +windows: + - layout: main-horizontal + options: + main-pane-height: 67% + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/examples/main-pane-height.yaml b/examples/main-pane-height.yaml index 3dbcc479f9..fe9a23f0e4 100644 --- a/examples/main-pane-height.yaml +++ b/examples/main-pane-height.yaml @@ -1,15 +1,15 @@ session_name: main-pane-height -start_directory: '~' +start_directory: "~" windows: -- layout: main-horizontal - options: - main-pane-height: 30 - panes: - - shell_command: - - top - start_directory: '~' - - shell_command: - - echo "hey" - - shell_command: - - echo "moo" - window_name: my window name + - layout: main-horizontal + options: + main-pane-height: 30 + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/examples/minimal.yaml b/examples/minimal.yaml new file mode 100644 index 0000000000..e4f86e47a1 --- /dev/null +++ b/examples/minimal.yaml @@ -0,0 +1,4 @@ +session_name: My tmux session +windows: + - panes: + - diff --git a/examples/options.yaml b/examples/options.yaml index 7de1cc5f1f..b66189820d 100644 --- a/examples/options.yaml +++ b/examples/options.yaml @@ -1,19 +1,19 @@ session_name: test window options -start_directory: '~' +start_directory: "~" global_options: default-shell: /bin/sh default-command: /bin/sh options: - main-pane-height: ${MAIN_PANE_HEIGHT} # works with env variables + main-pane-height: ${MAIN_PANE_HEIGHT} # works with env variables windows: -- layout: main-horizontal - options: - automatic-rename: on - panes: - - shell_command: - - man echo - start_directory: '~' - - shell_command: - - echo "hey" - - shell_command: - - echo "moo" + - layout: main-horizontal + options: + automatic-rename: on + panes: + - shell_command: + - man echo + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" diff --git a/examples/pane-shell.json b/examples/pane-shell.json new file mode 100644 index 0000000000..1a4b6af487 --- /dev/null +++ b/examples/pane-shell.json @@ -0,0 +1,40 @@ +{ + "session_name": "Pane shell example", + "windows": [ + { + "window_name": "first", + "window_shell": "/usr/bin/python2", + "layout": "even-vertical", + "suppress_history": false, + "options": { + "remain-on-exit": true + }, + "panes": [ + { + "shell": "/usr/bin/python3", + "shell_command": [ + "print('This is python 3')" + ] + }, + { + "shell": "/usr/bin/vim -u none", + "shell_command": [ + "iAll panes have the `remain-on-exit` setting on.", + "When you exit out of the shell or application, the panes will remain.", + "Use tmux command `:kill-pane` to remove the pane.", + "Use tmux command `:respawn-pane` to restart the shell in the pane.", + "Use and then `:q!` to get out of this vim window. :-)" + ] + }, + { + "shell_command": [ + "print('Hello World 2')" + ] + }, + { + "shell": "/usr/bin/top" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/pane-shell.yaml b/examples/pane-shell.yaml new file mode 100644 index 0000000000..0fee4fdea6 --- /dev/null +++ b/examples/pane-shell.yaml @@ -0,0 +1,22 @@ +session_name: Pane shell example +windows: + - window_name: first + window_shell: /usr/bin/python2 + layout: even-vertical + suppress_history: false + options: + remain-on-exit: true + panes: + - shell: /usr/bin/python3 + shell_command: + - print('This is python 3') + - shell: /usr/bin/vim -u none + shell_command: + - iAll panes have the `remain-on-exit` setting on. + - When you exit out of the shell or application, the panes will remain. + - Use tmux command `:kill-pane` to remove the pane. + - Use tmux command `:respawn-pane` to restart the shell in the pane. + - Use and then `:q!` to get out of this vim window. :-) + - shell_command: + - print('Hello World 2') + - shell: /usr/bin/top diff --git a/examples/plugin-system.json b/examples/plugin-system.json new file mode 100644 index 0000000000..4309676b30 --- /dev/null +++ b/examples/plugin-system.json @@ -0,0 +1,24 @@ +{ + "session_name": "plugin-system", + "plugins": [ + "tmuxp_plugin_extended_build.plugin.PluginExtendedBuild" + ], + "windows": [ + { + "window_name": "editor", + "layout": "tiled", + "shell_command_before": [ + "cd ~/" + ], + "panes": [ + { + "shell_command": [ + "cd /var/log", + "ls -al | grep *.log" + ] + }, + "echo \"hello world\"" + ] + } + ] +} \ No newline at end of file diff --git a/examples/plugin-system.yaml b/examples/plugin-system.yaml new file mode 100644 index 0000000000..15581de715 --- /dev/null +++ b/examples/plugin-system.yaml @@ -0,0 +1,13 @@ +session_name: plugin-system +plugins: + - "tmuxp_plugin_extended_build.plugin.PluginExtendedBuild" +windows: + - window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep *.log + - echo "hello world" diff --git a/examples/session-environment.json b/examples/session-environment.json index a2f50b71af..3c31e12ec3 100644 --- a/examples/session-environment.json +++ b/examples/session-environment.json @@ -1,15 +1,33 @@ { "environment": { "EDITOR": "/usr/bin/vim", - "HOME": "/tmp/hm", - }, + "DJANGO_SETTINGS_MODULE": "my_app.settings.local", + "SERVER_PORT": "8009" + }, "windows": [ { "panes": [ - null, - ], - "window_name": "Blank pane test" - }, - ], + "./manage.py runserver 0.0.0.0:${SERVER_PORT}" + ], + "window_name": "Django project" + }, + { + "environment": { + "DJANGO_SETTINGS_MODULE": "my_app.settings.local", + "SERVER_PORT": "8010" + }, + "panes": [ + "./manage.py runserver 0.0.0.0:${SERVER_PORT}", + { + "environment": { + "DJANGO_SETTINGS_MODULE": "my_app.settings.local-testing", + "SERVER_PORT": "8011" + }, + "shell_command": "./manage.py runserver 0.0.0.0:${SERVER_PORT}" + } + ], + "window_name": "Another Django project" + } + ], "session_name": "Environment variables test" -} +} \ No newline at end of file diff --git a/examples/session-environment.yaml b/examples/session-environment.yaml index f5dee52164..a1091e7a18 100644 --- a/examples/session-environment.yaml +++ b/examples/session-environment.yaml @@ -1,10 +1,19 @@ session_name: Environment variables test environment: EDITOR: /usr/bin/vim - HOME: /tmp/hm + DJANGO_SETTINGS_MODULE: my_app.settings.local + SERVER_PORT: "8009" windows: - # Emptiness will simply open a blank pane, if no shell_command_before. - # All these are equivalent - - window_name: Blank pane test + - window_name: Django project panes: - - \ No newline at end of file + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - window_name: Another Django project + environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local + SERVER_PORT: "8010" + panes: + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local-testing + SERVER_PORT: "8011" + shell_command: ./manage.py runserver 0.0.0.0:${SERVER_PORT} diff --git a/examples/shorthands.yaml b/examples/shorthands.yaml index 5fb88100fb..cd4afaf39a 100644 --- a/examples/shorthands.yaml +++ b/examples/shorthands.yaml @@ -2,8 +2,8 @@ session_name: shorthands windows: - window_name: long form panes: - - shell_command: - - echo 'did you know' - - echo 'you can inline' - - shell_command: echo 'single commands' - - echo 'for panes' + - shell_command: + - echo 'did you know' + - echo 'you can inline' + - shell_command: echo 'single commands' + - echo 'for panes' diff --git a/examples/skip-send-pane-level.json b/examples/skip-send-pane-level.json new file mode 100644 index 0000000000..2fa8a2535c --- /dev/null +++ b/examples/skip-send-pane-level.json @@ -0,0 +1,20 @@ +{ + "session_name": "Skip command execution (pane-level)", + "windows": [ + { + "panes": [ + { + "shell_command": "echo \"___$((1 + 3))___\"", + "enter": false + }, + { + "shell_command": [ + "echo \"___$((1 + 3))___\"\\;", + "echo \"___$((1 + 3))___\"" + ], + "enter": false + } + ] + } + ] +} diff --git a/examples/skip-send-pane-level.yaml b/examples/skip-send-pane-level.yaml new file mode 100644 index 0000000000..2e988e8924 --- /dev/null +++ b/examples/skip-send-pane-level.yaml @@ -0,0 +1,9 @@ +session_name: Skip command execution (pane-level) +windows: + - panes: + - shell_command: echo "___$((1 + 3))___" + enter: false + - shell_command: + - echo "___$((1 + 3))___"\; + - echo "___$((1 + 3))___" + enter: false diff --git a/examples/skip-send.json b/examples/skip-send.json new file mode 100644 index 0000000000..db97609d7a --- /dev/null +++ b/examples/skip-send.json @@ -0,0 +1,18 @@ +{ + "session_name": "Skip command execution (command-level)", + "windows": [ + { + "panes": [ + { + "shell_command": [ + "echo \"___$((11 + 1))___\"", + { + "cmd": "echo \"___$((1 + 3))___\"", + "enter": false + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/skip-send.yaml b/examples/skip-send.yaml new file mode 100644 index 0000000000..150aa64f0e --- /dev/null +++ b/examples/skip-send.yaml @@ -0,0 +1,9 @@ +session_name: Skip command execution (command-level) +windows: + - panes: + - shell_command: + # You can see this + - echo "___$((11 + 1))___" + # This is skipped + - cmd: echo "___$((1 + 3))___" + enter: false diff --git a/examples/sleep-pane-level.json b/examples/sleep-pane-level.json new file mode 100644 index 0000000000..92daeeab77 --- /dev/null +++ b/examples/sleep-pane-level.json @@ -0,0 +1,27 @@ +{ + "session_name": "Pause / skip command execution (pane-level)", + "windows": [ + { + "panes": [ + { + "sleep_before": 2, + "shell_command": [ + "echo \"___$((11 + 1))___\"", + { + "cmd": "echo \"___$((1 + 3))___\"" + }, + { + "cmd": "echo \"___$((1 + 3))___\"" + }, + { + "cmd": "echo \"Stuff rendering here!\"" + }, + { + "cmd": "echo \"2 seconds later\"" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/sleep-pane-level.yaml b/examples/sleep-pane-level.yaml new file mode 100644 index 0000000000..00a1ef2151 --- /dev/null +++ b/examples/sleep-pane-level.yaml @@ -0,0 +1,11 @@ +session_name: Pause / skip command execution (pane-level) +windows: + - panes: + - # Wait 2 seconds before sending all commands in this pane + sleep_before: 2 + shell_command: + - echo "___$((11 + 1))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "Stuff rendering here!" + - cmd: echo "2 seconds later" diff --git a/examples/sleep-virtualenv.yaml b/examples/sleep-virtualenv.yaml new file mode 100644 index 0000000000..e226b6e3f2 --- /dev/null +++ b/examples/sleep-virtualenv.yaml @@ -0,0 +1,11 @@ +session_name: virtualenv +shell_command_before: + # - cmd: source $(poetry env info --path)/bin/activate + # - cmd: source `pipenv --venv`/bin/activate + - cmd: source .venv/bin/activate + sleep_before: 1 + sleep_after: 1 +windows: + - panes: + - shell_command: + - ./manage.py runserver diff --git a/examples/sleep.json b/examples/sleep.json new file mode 100644 index 0000000000..2136cab086 --- /dev/null +++ b/examples/sleep.json @@ -0,0 +1,28 @@ +{ + "session_name": "Pause / skip command execution (command-level)", + "windows": [ + { + "panes": [ + { + "shell_command": [ + "echo \"___$((11 + 1))___\"", + { + "cmd": "echo \"___$((1 + 3))___\"", + "sleep_before": 2 + }, + { + "cmd": "echo \"___$((1 + 3))___\"" + }, + { + "cmd": "echo \"Stuff rendering here!\"", + "sleep_after": 2 + }, + { + "cmd": "echo \"2 seconds later\"" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/sleep.yaml b/examples/sleep.yaml new file mode 100644 index 0000000000..cf1e7f7ebe --- /dev/null +++ b/examples/sleep.yaml @@ -0,0 +1,16 @@ +session_name: Pause / skip command execution (command-level) +windows: + - panes: + - shell_command: + # Executes immediately + - echo "___$((11 + 1))___" + # Delays before sending 2 seconds + - cmd: echo "___$((1 + 3))___" + sleep_before: 2 + # Executes immediately + - cmd: echo "___$((1 + 3))___" + # Pauses 2 seconds after + - cmd: echo "Stuff rendering here!" + sleep_after: 2 + # Executes after earlier commands (after 2 sec) + - cmd: echo "2 seconds later" diff --git a/examples/start-directory.json b/examples/start-directory.json index 68bb55b53c..2bc00033d5 100644 --- a/examples/start-directory.json +++ b/examples/start-directory.json @@ -51,9 +51,9 @@ { "shell_command": [ "echo '\\033c", - "./ is relative to config file location", - "../ will be parent of config file", - "./test will be \\\"test\\\" dir inside dir of config file'" + "./ is relative to workspace file location", + "../ will be parent of workspace file", + "./test will be \\\"test\\\" dir inside dir of workspace file'" ] }, { diff --git a/examples/start-directory.yaml b/examples/start-directory.yaml index 5c7051234c..dcecf67b87 100644 --- a/examples/start-directory.yaml +++ b/examples/start-directory.yaml @@ -4,37 +4,38 @@ windows: - window_name: should be /var/ panes: - shell_command: - - echo "\033c - - it trickles down from session-level" + - echo "\033c + - it trickles down from session-level" - echo hello - window_name: should be /var/log start_directory: log panes: - shell_command: - - echo '\033c - - window start_directory concatenates to session start_directory - - if it is not absolute' + - echo '\033c + - window start_directory concatenates to session start_directory + - if it is not absolute' - echo hello - window_name: should be ~ - start_directory: '~' + start_directory: "~" panes: - - shell_command: - - 'echo \\033c ~ has precedence. note: remember to quote ~ in YAML' - - echo hello + - shell_command: + - 'echo \\033c ~ has precedence. note: remember to quote ~ in YAML' + - echo hello - window_name: should be /bin start_directory: /bin panes: - - echo '\033c absolute paths also have precedence.' - - echo hello - - window_name: should be config's dir + - echo '\033c absolute paths also have precedence.' + - echo hello + - window_name: should be workspace file's dir + start_directory: ./ panes: - shell_command: - - echo '\033c - - ./ is relative to config file location - - ../ will be parent of config file - - ./test will be \"test\" dir inside dir of config file' + - echo '\033c + - ./ is relative to workspace file location + - ../ will be parent of workspace file + - ./test will be \"test\" dir inside dir of workspace file' - shell_command: - - echo '\033c - - This way you can load up workspaces from projects and maintain - - relative paths.' + - echo '\033c + - This way you can load up workspaces from projects and maintain + - relative paths.' diff --git a/examples/suppress-history.yaml b/examples/suppress-history.yaml index aa534d5a8a..6e12823f7c 100644 --- a/examples/suppress-history.yaml +++ b/examples/suppress-history.yaml @@ -1,29 +1,29 @@ session_name: suppress suppress_history: false windows: -- window_name: appended - focus: true - suppress_history: false - panes: - - echo "window in the history!" + - window_name: appended + focus: true + suppress_history: false + panes: + - echo "window in the history!" -- window_name: suppressed - suppress_history: true - panes: - - echo "window not in the history!" + - window_name: suppressed + suppress_history: true + panes: + - echo "window not in the history!" -- window_name: default - panes: - - echo "session in the history!" + - window_name: default + panes: + - echo "session in the history!" -- window_name: mixed - suppress_history: false - panes: - - shell_command: - - echo "command in the history!" + - window_name: mixed suppress_history: false - - shell_command: - - echo "command not in the history!" - suppress_history: true - - shell_command: - - echo "window in the history!" \ No newline at end of file + panes: + - shell_command: + - echo "command in the history!" + suppress_history: false + - shell_command: + - echo "command not in the history!" + suppress_history: true + - shell_command: + - echo "window in the history!" diff --git a/justfile b/justfile new file mode 100644 index 0000000000..3c2fd193da --- /dev/null +++ b/justfile @@ -0,0 +1,139 @@ +# justfile for tmuxp +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# File patterns +py_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$' 2> /dev/null" +test_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]\\(yaml\\|py\\)$' 2> /dev/null" +doc_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# List all available commands +default: + @just --list + +# Run tests with pytest +[group: 'test'] +test *args: + uv run py.test {{ args }} + +# Run tests then start continuous testing with pytest-watcher +[group: 'test'] +start: + just test + uv run ptw . + +# Watch files and run tests on change (requires entr) +[group: 'test'] +watch-test: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ test_files }} | entr -c just test + else + just test + just _entr-warn + fi + +# Build documentation +[group: 'docs'] +build-docs: + just -f docs/justfile html + +# Watch files and rebuild docs on change +[group: 'docs'] +watch-docs: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ doc_files }} | entr -c just build-docs + else + just build-docs + just _entr-warn + fi + +# Serve documentation +[group: 'docs'] +serve-docs: + just -f docs/justfile serve + +# Watch and serve docs simultaneously +[group: 'docs'] +dev-docs: + #!/usr/bin/env bash + set -euo pipefail + just watch-docs & + just serve-docs + +# Start documentation server with auto-reload +[group: 'docs'] +start-docs: + just -f docs/justfile start + +# Start documentation design mode (watches static files) +[group: 'docs'] +design-docs: + just -f docs/justfile design + +# Format code with ruff +[group: 'lint'] +ruff-format: + uv run ruff format . + +# Run ruff linter +[group: 'lint'] +ruff: + uv run ruff check . + +# Watch files and run ruff on change +[group: 'lint'] +watch-ruff: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just ruff + else + just ruff + just _entr-warn + fi + +# Run mypy type checker +[group: 'lint'] +mypy: + uv run mypy $({{ py_files }}) + +# Watch files and run mypy on change +[group: 'lint'] +watch-mypy: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just mypy + else + just mypy + just _entr-warn + fi + +# Format markdown files with prettier +[group: 'format'] +format-markdown: + npx prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES + +# Run monkeytype to collect runtime types +[group: 'typing'] +monkeytype-create: + uv run monkeytype run $(uv run which py.test) + +# Apply collected monkeytype annotations +[group: 'typing'] +monkeytype-apply: + uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' + +[private] +_entr-warn: + @echo "----------------------------------------------------------" + @echo " ! File watching functionality non-operational ! " + @echo " " + @echo "Install entr(1) to automatically run tasks on file change." + @echo "See https://eradman.com/entrproject/ " + @echo "----------------------------------------------------------" diff --git a/manual/0.8.txt b/manual/0.8.txt deleted file mode 100644 index 0550ae7d33..0000000000 --- a/manual/0.8.txt +++ /dev/null @@ -1,808 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28dqUuVv] [-f file] [-L socket-name] [-S socket-path] - [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer; it enables a number of terminals to be - accessed and controlled from a single terminal. - - tmux runs as a server-client system. A server is created automatically - when necessary and holds a number of sessions, each of which may have a - number of windows linked to it. A window may be split on screen into one - or more panes, each of which is a separate terminal. Any number of - clients may connect to a session, or the server may be controlled by - issuing commands with tmux. Communication takes place through a socket, - by default placed in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, indicates the terminal supports 88 colours. - - -d Force tmux to assume the terminal supports default colours. - - -f file Specify an alternative configuration file. By default, - tmux will look for a config file at ~/.tmux.conf. The con- - figuration file is a set of tmux commands which are exe- - cuted in sequence when the server is first started. - - -q Prevent the server sending various information messages, - for example when window flags are altered. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -U Unlock the server. - - -u Instruct tmux that the terminal support UTF-8. - - -V Print program version. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the pid of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, and described in the following sections. If no com- - mand and flags is specified, the new-session command is - assumed. - -QUICK START - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - $ - - Within an active session, a new window may be created by typing 'C-b' - (ctrl-b, known as the prefix key) followed by the 'c' key. - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'Q' to exit from it. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (ctrl-b) by default, followed by a command key. - - Some of the default key bindings include: - - 'd' Detach current client. - 'c' Create new window. - 'n' Change to next window in the current session. - 'p' Change to previous window in the current session. - 'l' Move to last (previously selected) window in the current session. - 't' Display a large clock. - '?' List current key bindings. - - A complete list may be obtained with the list-keys command (bound to '?' - by default). Key bindings may be changed with the bind-key and - unbind-key commands. - -HISTORY - tmux maintains a configurable history buffer for each window. By - default, up to 2000 lines are kept, this can be altered with the - history-limit option (see the set-option command below). - -MODES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The others are: - - output mode - This is entered when a command which produces output, such as - list-keys, is executed from a key binding. - - scroll mode - This is entered with the scroll-mode command (bound to '=' by - default) and permits the window history buffer to be inspected. - - copy mode - This permits a section of a window or its history to be copied to - a paste buffer for later insertion into another window. This - mode is entered with the copy-mode command, bound to ['' by - default. - - The keys available depend on whether emacs(1) or vi(1) mode is selected - (see the mode-keys option). The following keys are supported as appro- - priate for the mode: - - Function vi emacs - Start of line 0 or ^ C-a - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - End of line $ C-e - Cursor left h Left - Next page C-f Page down - Next word w M-f - Previous page C-u Page up - Previous word b M-b - Quit mode q Escape - Cursor right l Right - Start selection Space C-Space - Cursor up k Up - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - -PANES AND LAYOUTS - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. - - Panes are numbered beginning from zero; in horizontal layouts zero is the - leftmost pane and in vertical the topmost. - - Panes may be arranged using several layouts. The layout may be cycled - with the next-layout command (bound to 'C-space' by default), the current - pane may be changed with the up-pane and down-pane commands and the - rotate-window and swap-pane commands may be used to swap panes without - changing the window layout. - - The following layouts are supported: - - manual Manual layout splits windows vertically (running across); only - with this layout may panes be resized using the resize-pane-up - and resize-pane-down commands. - - active-only - Only the active pane is shown - all other panes are hidden. - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - left-vertical - A large (81 column) pane is shown on the left of the window and - the remaining panes are spread from top to bottom in the leftover - space to the right. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session or target-window. These specify the client, session or - window which a command should affect. target-client is the name of the - pty(4) file to which the client is connected, for example /dev/ttyp1. - Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command); or the name of a client as for target-client, in - this case, the session attached to the client is used. An fnmatch(3) - pattern may be used to match the session name. If a session is omitted - when required, tmux attempts to use the current session; if no current - session is available, the most recently created is chosen. If no client - is specified, the current client is chosen, if possible, or an error is - reported. - - target-window specifies a window in the form session:index, for example - mysession:1. The session is in the same form as for target-session. - session, index or both may be omitted. If session is omitted, the same - rules as for target-session are followed; if index is not present, the - current window for the given session is used. When the argument does not - contain a colon (:), tmux first attempts to parse it as window index; if - that fails, an attempt is made to match a session or client name. - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon (' - ; '); commands are executed sequentially from left to right. A literal - semicolon may be included by escaping it with a backslash (for example, - when specifying a command sequence to bind-key). - - Examples include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key D detach-client \; lock-server - - The following commands are available: - - attach-session [-d] [-t target-session] - (alias: attach) - Create a new client in the current terminal and attach it to a - session. If -d is specified, any other clients attached to the - session are detached. - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - bind-key [-r] key command [arguments] - (alias: bind) - Bind key key to command. Keys may be specified prefixed with - 'C-' or '^' for ctrl keys, or 'M-' for alt (meta) keys. The -r - flag indicates this key may repeat, see the repeat-time option. - - break-pane [-d] [-p pane-index] [-t target-window] - Break the current pane off from its containing window to make it - the only pane in a new window. If -d is given, the new window - does not become the current window. - - choose-session [-t target-window] - Put a window into session choice mode, where the session for the - current client may be selected interactively from a list. This - command works only from inside tmux. - - choose-window [-t target-window] - Put a window into window choice mode, where the window for the - session attached to the current client may be selected interac- - tively from a list. This command works only from inside tmux. - - clock-mode [-t target-window] - Display a large clock. - - command-prompt [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command; any %% in the template will - be replaced by what is entered at the prompt. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - copy-mode [-u] [-t target-window] - Enter copy mode. The -u option scrolls one page up. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - down-pane [-p pane-index] [-t target-window] - (alias: downp) - Move down a pane. - - find-window [-t target-window] match-string - (alias: findw) - Search for match-string in window names, titles, and visible con- - tent (but not history). If only one window is matched, it'll be - automatically selected, otherwise a choice list is shown. This - command only works from inside tmux. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-pane [-p pane-index] [-t target-window] - (alias: killp) - Destroy the given pane. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-keys - (alias: lsk) - List all key bindings. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - lock-server - (alias: lock) - Lock the server until a password is entered. - - move-window [-d] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-session [-d] [-n window-name] [-s session-name] [command] - (alias: new) - Create a new session with name session-name. The new session is - attached to the current terminal unless -d is given. window-name - and command are the name of and command to execute in the initial - window. - - new-window [-d] [-n window-name] [-t target-window] [command] - (alias: neww) - Create a new window. If -d is given, the session does not make - the new window the current window. target-window represents the - window to be created. command is the command to execute. If - command is not specified, the default command is used. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-t target-session] - (alias: next) - Move to the next window in the session. - - paste-buffer [-d] [-b buffer-index] [-t target-window] - (alias: pasteb) - Insert the contents of a paste buffer into the current window. - - previous-window [-t target-session] - (alias: prev) - Move to the previous window in the session. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane-down [-p pane-index] [-t target-window] [adjustment] - (alias: resizep-down) - - resize-pane-up [-p pane-index] [-t target-window] [adjustment] - (alias: resizep-up) - Resize a pane. The adjustment is given in lines (the default is - 1). - - respawn-window [-k] [-t target-window] [command] - (alias: respawnw) - Reactive a window in which the command has exited (see the - remain-on-exit window option). If command is not given, the com- - mand used when the window was created is executed. The window - must be already inactive, unless -k is given, in which case any - existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - scroll-mode [-u] [-t target-window] - Enter scroll mode. The -u has the same meaning as in the - copy-mode command. - - select-pane [-p pane-index] [-t target-window] - (alias: selectp) - Make pane pane-index the active pane in window target-window. - - select-prompt [-t target-client] - Open a prompt inside target-client allowing a window index to be - entered interactively. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - send-keys [-t target-window] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-window] - Send the prefix key to a window as if it was pressed. - - server-info - (alias: info) - Show server information and terminal details. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - set-option [-gu] [-t target-session] option value - (alias: set) - Set an option. If -g is specified, the option is set as a global - option. Global options apply to all sessions which don't have - the option explicitly set. If -g is not used, the option applies - only to target-session. The -u flag unsets an option, so a ses- - sion inherits the option from the global options - it is not pos- - sible to unset a global option. - - Possible options are: - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command command - Set the command used for new windows (if not specified - when the window is created) to command. The default is - ``exec $SHELL''. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is the current working directory when the server is - started. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the server after number seconds of inactivity. The - default is off (set to 0). This has no effect as a ses- - sion option; it must be set as a global option using -g. - - message-attr attributes - Set status line message attributes, where attributes is - either default or a comma-delimited list of one or more - of: bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white or default. - - message-fg colour - Set status line message foreground colour. - - prefix key - Set the current prefix key. - - repeat-time number - Allow multiple commands to be entered without pressing - the prefix-key again in the specified number milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys of the up-pane, - down-pane, resize-pane-up, and resize-pane-down commands. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code and the terminal appears to be an xterm. This - option is enabled by default. Note that elinks(1) will - only attempt to set the window title if the STY environ- - ment variable is set. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-keys [vi | emacs] - Use vi(1) - or emacs(1) -style key bindings in the status - line, for example at the command prompt. Defaults to - emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character pairs: - - Character pair Replaced with - #(command) First line of command's output - #H Hostname of local host - #S Session name - #T Current window title - ## A literal '#' - - Where appropriate, these may be prefixed with a number to - specify the maximum length, for example '#24T'. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the date and time will be shown. As with - status-left, string will be passed to strftime(3) and - character pairs are replaced. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - set-password [-c] password - (alias: pass) - Set the server password. If the -c option is given, a pre- - encrypted password may be specified. By default, the password is - blank, thus any entered password will be accepted when unlocking - the server (see the lock-server command). To prevent variable - expansion when an encrypted password is read from a configuration - file, enclose it in single quotes ('). - - set-window-option [-gu] [-t target-window] option value - (alias: setw) - Set a window-specific option. The -g and -u flags work similarly - to the set-option command. - - Supported options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi(1) - or emacs(1) -style key bindings in scroll and - copy modes. Key bindings default to emacs. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as shift, meta or ctrl. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - - show-options [-t target-session] option value - (alias: show) - Show the currently set options. If a target-session is speci- - fied, the options for that session are shown; otherwise, the - global options are listed. - - show-window-options [-t target-window] option value - (alias: showw) - List the current options for the given window. - - source-file path - (alias: source) - Execute commands from path. - - split-window [-d] [-l lines | -p percentage] [-t target-window] [command] - (alias: splitw) - Creates a new window by splitting it vertically. The -l and -p - options specify the size of the new window in lines, or as a per- - centage, respectively. All other options have the same meaning - as in the new-window command. - - A few notes with regard to panes: - 1. If attempting to split a window with less than eight lines, - an error will be shown. - 2. If the window is resized, as many panes are shown as can fit - without reducing them below four lines. - 3. The minimum pane size is four lines (including the separator - line). - 4. The panes are indexed from top (0) to bottom, with no num- - bers skipped. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c -target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - swap-pane [-dDU] [-p src-index] [-t target-window] [-q dst-index] - (alias: swapp) - Swap two panes within a window. If -U is used, the pane is - swapped with the pane above (before it numerically); -D swaps - with the pane below (the next numerically); or dst-index may be - give to swap with a specific pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - switch-client [-c target-client -t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - - unbind-key key - (alias: unbind) - Unbind the key bound to key. - - unlink-window [-t target-window] - (alias: unlinkw) - Unlink target-window. A window may be unlinked only if it is - linked to multiple sessions - windows may not be linked to no - sessions. - - up-pane [-p pane-index] [-t target-window] - (alias: upp) - Move up a pane. - -FILES - ~/.tmux.conf - default tmux configuration file - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD April 20, 2009 BSD diff --git a/manual/0.9.txt b/manual/0.9.txt deleted file mode 100644 index e0fb1eed6e..0000000000 --- a/manual/0.9.txt +++ /dev/null @@ -1,917 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28dqUuv] [-f file] [-L socket-name] [-S socket-path] - [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - accessed and controlled from a single terminal. - - tmux runs as a server-client system. A server is created automatically - when necessary and holds a number of sessions, each of which may have a - number of windows linked to it. A window may be split on screen into one - or more panes, each of which is a separate terminal. Any number of - clients may connect to a session, or the server may be controlled by - issuing commands with tmux. Communication takes place through a socket, - by default placed in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -d Force tmux to assume the terminal supports default colours. - - -f file Specify an alternative configuration file. By default, - tmux will look for a config file at ~/.tmux.conf. The con- - figuration file is a set of tmux commands which are exe- - cuted in sequence when the server is first started. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -q Prevent the server sending various informational messages, - for example when window flags are altered. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -U Unlock the server. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mand and flags are specified, the new-session command is - assumed. - -QUICK START - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - Some of the default key bindings include: - - c Create new window. - d Detach current client. - l Move to last (previously selected) window in the current ses- - sion. - n Change to next window in the current session. - p Change to previous window in the current session. - t Display a large clock. - ? List current key bindings. - - A complete list may be obtained with the list-keys command (bound to '?' - by default). Key bindings may be changed with the bind-key and - unbind-key commands. - -HISTORY - tmux maintains a configurable history buffer for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command below). - -MODES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The others are: - - output mode - This is entered when a command which produces output, such as - list-keys, is executed from a key binding. - - scroll mode - This is entered with the scroll-mode command (bound to '=' by - default) and permits the window history buffer to be inspected. - - copy mode - This permits a section of a window or its history to be copied to - a paste buffer for later insertion into another window. This - mode is entered with the copy-mode command, bound to ['' by - default. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Start of line 0 or ^ C-a - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - End of line $ C-e - Cursor left h Left - Next page C-f Page down - Next word w M-f - Previous page C-u Page up - Previous word b M-b - Quit mode q Escape - Cursor right l Right - Start selection Space C-Space - Cursor up k Up - Paste buffer p C-y - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - -PANES AND LAYOUTS - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. - - Panes are numbered beginning from zero; in horizontal layouts zero is the - leftmost pane and in vertical the topmost. - - Panes may be arranged using several layouts. The layout may be cycled - with the next-layout command (bound to 'C-space' by default), the current - pane may be changed with the up-pane and down-pane commands and the - rotate-window and swap-pane commands may be used to swap panes without - changing the window layout. - - The following layouts are supported: - - active-only - Only the active pane is shown - all other panes are hidden. - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - manual Manual layout splits windows vertically (running across); only - with this layout may panes be resized using the resize-pane com- - mand. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. The - window list shows the index, name and (if any) flag of the windows - present in the current session in ascending numerical order. The flag is - one of the following symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session or target-window. These specify the client, session or - window which a command should affect. target-client is the name of the - pty(4) file to which the client is connected, for example /dev/ttyp1. - Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client, target-client, in which - case the session attached to the client is used. An fnmatch(3) pattern - may be used to match the session name. If a session is omitted when - required, tmux attempts to use the current session; if no current session - is available, the most recently created is chosen. If no client is spec- - ified, the current client is chosen, if possible, or an error is - reported. - - target-window specifies a window in the form session:index, for example - mysession:1. The session is in the same form as for target-session. - session, index or both may be omitted. If session is omitted, the same - rules as for target-session are followed; if index is not present, the - current window for the given session is used. When the argument does not - contain a colon, tmux first attempts to parse it as window index; if that - fails, an attempt is made to match a session or client name. - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Examples include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key D detach-client \; lock-server - - The following commands are available: - - attach-session [-d] [-t target-session] - (alias: attach) - Create a new client in the current terminal and attach it to a - session. If -d is specified, any other clients attached to the - session are detached. - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - bind-key [-r] key command [arguments] - (alias: bind) - Bind key key to command. Keys may be specified prefixed with - 'C-' or '^' for Ctrl keys, or 'M-' for Alt (meta) keys. The -r - flag indicates this key may repeat, see the repeat-time option. - - break-pane [-d] [-p pane-index] [-t target-window] - (alias: breakp) - Break the current pane off from its containing window to make it - the only pane in a new window. If -d is given, the new window - does not become the current window. - - choose-session [-t target-window] - Put a window into session choice mode, where the session for the - current client may be selected interactively from a list. This - command works only from inside tmux. - - choose-window [-t target-window] - Put a window into window choice mode, where the window for the - session attached to the current client may be selected interac- - tively from a list. This command works only from inside tmux. - - clear-history [-p pane-index] [-t target-window] - (alias: clearhist) - Remove and free the history for the specified pane. - - clock-mode [-t target-window] - Display a large clock. - - command-prompt [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command; any %% in the template will - be replaced by what is entered at the prompt. - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - copy-mode [-u] [-t target-window] - Enter copy mode. The -u option scrolls one page up. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - down-pane [-p pane-index] [-t target-window] - (alias: downp) - Move down a pane. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-pane [-p pane-index] [-t target-window] - (alias: killp) - Destroy the given pane. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-keys - (alias: lsk) - List all key bindings. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - lock-server - (alias: lock) - Lock the server until a password is entered. - - move-window [-d] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-session [-d] [-n window-name] [-s session-name] [command] - (alias: new) - Create a new session with name session-name. The new session is - attached to the current terminal unless -d is given. window-name - and command are the name of and command to execute in the initial - window. - - new-window [-d] [-n window-name] [-t target-window] [command] - (alias: neww) - Create a new window. If -d is given, the session does not make - the new window the current window. target-window represents the - window to be created. command is the command to execute. If - command is not specified, the default command is used. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - paste-buffer [-d] [-b buffer-index] [-t target-window] - (alias: pasteb) - Insert the contents of a paste buffer into the current window. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DU] [-p pane-index] [-t target-window] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default) or downward with -D. - The adjustment is given in lines (the default is 1). - - respawn-window [-k] [-t target-window] [command] - (alias: respawnw) - Reactive a window in which the command has exited (see the - remain-on-exit window option). If command is not given, the com- - mand used when the window was created is executed. The window - must be already inactive, unless -k is given, in which case any - existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - scroll-mode [-u] [-t target-window] - Enter scroll mode. The -u has the same meaning as in the - copy-mode command. - - select-layout [-t target-window] layout-name - (alias: selectl) - Choose a specific layout for a window. - - select-pane [-p pane-index] [-t target-window] - (alias: selectp) - Make pane pane-index the active pane in window target-window. - - select-prompt [-t target-client] - Open a prompt inside target-client allowing a window index to be - entered interactively. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - send-keys [-t target-window] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-window] - Send the prefix key to a window as if it was pressed. - - server-info - (alias: info) - Show server information and terminal details. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - set-option [-gu] [-t target-session] option value - (alias: set) - Set an option. If -g is specified, the option is set as a global - option. Global options apply to all sessions which don't have - the option explicitly set. If -g is not used, the option applies - only to target-session. The -u flag unsets an option, so a ses- - sion inherits the option from the global options - it is not pos- - sible to unset a global option. - - Possible options are: - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command command - Set the command used for new windows (if not specified - when the window is created) to command. The default is - an empty string, which instructs tmux to create a login - shell using the SHELL environment variable or, if it is - unset, the user's shell returned by getpwuid(3). - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is the current working directory when the server is - started. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the server after number seconds of inactivity. The - default is off (set to 0). This has no effect as a ses- - sion option; it must be set as a global option using -g. - - message-attr attributes - Set status line message attributes, where attributes is - either default or a comma-delimited list of one or more - of: bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white or default. - - message-fg colour - Set status line message foreground colour. - - prefix key - Set the current prefix key. - - repeat-time number - Allow multiple commands to be entered without pressing - the prefix-key again in the specified number milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys of the up-pane, - down-pane, resize-pane-up, and resize-pane-down commands. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code and the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. Defaults to emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character pairs: - - Character pair Replaced with - #(command) First line of command's output - #H Hostname of local host - #S Session name - #T Current window title - ## A literal '#' - - Where appropriate, these may be prefixed with a number to - specify the maximum length, for example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the date and time will be shown. As with - status-left, string will be passed to strftime(3), char- - acter pairs are replaced, and UTF-8 is dependent on the - status-utf8 option. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - set-password [-c] password - (alias: pass) - Set the server password. If the -c option is given, a pre- - encrypted password may be specified. By default, the password is - blank, thus any entered password will be accepted when unlocking - the server (see the lock-server command). To prevent variable - expansion when an encrypted password is read from a configuration - file, enclose it in single quotes ('). - - set-window-option [-gu] [-t target-window] option value - (alias: setw) - Set a window-specific option. The -g and -u flags work similarly - to the set-option command. - - Supported options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-width width - - main-pane-height height - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in scroll and copy - modes. Key bindings default to emacs. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - - show-options [-t target-session] option value - (alias: show) - Show the currently set options. If a target-session is speci- - fied, the options for that session are shown; otherwise, the - global options are listed. - - show-window-options [-t target-window] option value - (alias: showw) - List the current options for the given window. - - source-file path - (alias: source) - Execute commands from path. - - split-window [-d] [-l lines | -p percentage] [-t target-window] [command] - (alias: splitw) - Creates a new window by splitting it vertically. The -l and -p - options specify the size of the new window in lines, or as a per- - centage, respectively. All other options have the same meaning - as in the new-window command. - - A few notes with regard to panes: - 1. If attempting to split a window with less than eight lines, - an error will be shown. - 2. If the window is resized, as many panes are shown as can fit - without reducing them below four lines. - 3. The minimum pane size is four lines (including the separator - line). - 4. The panes are indexed from top (0) to bottom, with no num- - bers skipped. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c -target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - swap-pane [-dDU] [-p src-index] [-t target-window] [-q dst-index] - (alias: swapp) - Swap two panes within a window. If -U is used, the pane is - swapped with the pane above (before it numerically); -D swaps - with the pane below (the next numerically); or dst-index may be - give to swap with a specific pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - switch-client [-c target-client -t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - - unbind-key key - (alias: unbind) - Unbind the key bound to key. - - unlink-window [-t target-window] - (alias: unlinkw) - Unlink target-window. A window may be unlinked only if it is - linked to multiple sessions - windows may not be linked to no - sessions. - - up-pane [-p pane-index] [-t target-window] - (alias: upp) - Move up a pane. - -FILES - ~/.tmux.conf Default tmux configuration file. - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.0.txt b/manual/1.0.txt deleted file mode 100644 index 83260edd71..0000000000 --- a/manual/1.0.txt +++ /dev/null @@ -1,1244 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28dlqUuv] [-f file] [-L socket-name] [-S socket-path] - [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -d Force tmux to assume the terminal supports default colours. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -q Prevent the server sending various informational messages, - for example when window flags are altered. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -U Unlock the server. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - Some of the default key bindings are: - - c Create a new window. - d Detach the current client. - l Move to the previously selected window. - n Change to the next window. - p Change to the previous window. - & Kill the current window. - , Rename the current window. - ? List all key bindings. - - A complete list may be obtained with the list-keys command (bound to '?' - by default). Key bindings may be changed with the bind-key and - unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently created is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Examples include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key D detach-client \; lock-server - -CLIENTS AND SESSIONS - The following commands are available: - - attach-session [-d] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - new-session [-d] [-n window-name] [-s session-name] [command] - (alias: new) - Create a new session with name session-name. The new session is - attached to the current terminal unless -d is given. window-name - and command are the name of and command to execute in the initial - window. - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The others are: - - output mode - This is entered when a command which produces output, such as - list-keys, is executed from a key binding. - - scroll mode - This is entered with the scroll-mode command (bound to '=' by - default) and permits the window history buffer to be inspected. - - copy mode - This permits a section of a window or its history to be copied to - a paste buffer for later insertion into another window. This - mode is entered with the copy-mode command, bound to ['' by - default. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor up k Up - Delete entire line d C-u - Delete to end of line D C-k - End of line $ C-e - Goto line g g - Next page C-f Page down - Next word w M-f - Paste buffer p C-y - Previous page C-u Page up - Previous word b M-b - Quit mode q Escape - Search again n n - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Transpose chars C-t - - These key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the window-choose command) or in output mode; and vi-copy - and emacs-copy used in copy and scroll modes. The tables may be viewed - with the list-keys command and keys modified or removed with bind-key and - unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The mode commands are as follows: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - scroll-mode [-u] [-t target-pane] - Enter scroll mode. The -u has the same meaning as in the - copy-mode command. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - up-pane and down-pane commands and the rotate-window and swap-pane com- - mands may be used to swap panes without changing their position. Panes - are numbered beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'C-space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time and display-panes-colour session - options. While the indicator is on screen, a pane may be - selected with the '0' to '9' keys. - - down-pane [-t target-pane] - (alias: downp) - Move down a pane. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - kill-pane [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - move-window [-d] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-dk] [-n window-name] [-t target-window] [command] - (alias: neww) - Create a new window. If -d is given, the session does not make - the new window the current window. target-window represents the - window to be created; if the target already exists an error is - shown, unless the -k flag is used, in which case it is destroyed. - command is the command to execute. If command is not specified, - the default command is used. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-window [-k] [-t target-window] [command] - (alias: respawnw) - Reactive a window in which the command has exited (see the - remain-on-exit window option). If command is not given, the com- - mand used when the window was created is executed. The window - must be already inactive, unless -k is given, in which case any - existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last layout used (if any) is reapplied. - - select-pane [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - split-window [-dhv] [-l size | -p percentage] [-t target-window] - [command] - (alias: splitw) - Creates a new pane by splitting the active pane: -h does a hori- - zontal split and -v a vertical split; if neither is specified, -v - is assumed. The -l and -p options specify the size of the new - window in lines (for vertical split) or in cells (for horizontal - split), or as a percentage, respectively. All other options have - the same meaning as in the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - - up-pane [-t target-pane] - (alias: upp) - Move up a pane. - -KEY BINDINGS - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. Keys may be specified prefixed with - 'C-' or '^' for Ctrl keys, or 'M-' for Alt (meta) keys. - - By default (without -t) the primary key bindings are modified - (those normally activated with the prefix key); in this case, if - -n is specified, it is not necessary to use the prefix key, - command is bound to key alone. The -r flag indicates this key - may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are enclosed - in square brackets. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. - - unbind-key [-cn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are two types of option: session options - and window options. - - Each individual session may have a set of session options, and there is a - separate set of global session options. Sessions which do not have a - particular option configured inherit the value from the global session - options. Session options are set or unset with the set-option command - and may be listed with the show-options command. The available session - options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agu] [-t target-session] option value - (alias: set) - Set a session option. With -a, and if the option expects a - string, value is appended to the existing setting. If -g is - specified, the global session option is set. The -u flag unsets - an option, so a session inherits the option from the global - options - it is not possible to unset a global option. - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command command - Set the command used for new windows (if not specified - when the window is created) to command, which may be any - sh(1) command. The default is an empty string, which - instructs tmux to create a login shell using the value of - the default-shell option. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is the current working directory when the server is - started. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - display-panes-colour colour - Set the colour used for the display-panes command. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the server after number seconds of inactivity. The - default is off (set to 0). This has no effect as a ses- - sion option; it must be set as a global option using -g. - When passwords are entered incorrectly, tmux follows the - behaviour of login(1) and ignores further password - attempts for an increasing timeout. - - message-attr attributes - Set status line message attributes, where attributes is - either default or a comma-delimited list of one or more - of: bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - palette, or default. - - message-fg colour - Set status line message foreground colour. - - prefix key - Set the current prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. Defaults to emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(command) First line of command's output - #[attributes] Colour or attribute change - #H Hostname of local host - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(command) form executes 'command' as a shell command - and inserts the first line of its output. #[attributes] - allows a comma-separated list of attributes to be speci- - fied, these may be 'fg=colour' to set the foreground - colour, 'bg=colour' to set the background colour, or one - of the attributes described under the message-attr - option. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, these may be prefixed with a number to - specify the maximum length, for example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the date and time will be shown. As with - status-left, string will be passed to strftime(3), char- - acter pairs are replaced, and UTF-8 is dependent on the - status-utf8 option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY WINDOWID SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-width width - main-pane-height height - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in scroll, copy and - choice modes. Key bindings default to emacs. - - mode-mouse [on | off] - Mouse state in modes. If on, tmux will respond to mouse - clicks by moving the cursor in copy mode or selecting an - option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. - - show-options [-g] [-t target-session] - (alias: show) - Show the session options for target session, or the global ses- - sion options with -g. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged with - the session environment overriding any variable present in both. This is - the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. The - window list shows the index, name and (if any) flag of the windows - present in the current session in ascending numerical order. The flag is - one of the following symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command. If -p is given, prompts is - a comma-separated list of prompts which are displayed in order; - otherwise a single prompt is displayed, constructed from template - if it is present, or ':' if not. Before the command is executed, - the first occurrence of the string '%%' and all occurrences of - '%1' are replaced by the response to the first prompt, the second - '%%' and all '%2' are replaced with the response to the second - prompt, and so on for further prompts. Up to nine prompt - responses may be replaced ('%1' to '%9'). - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - display-message [-t target-client] [message] - (alias: display) - Display a message (see the status-left option below) in the sta- - tus line. - - select-prompt [-t target-client] - Open a prompt inside target-client allowing a window index to be - entered interactively. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-t target-window] - (alias: pasteb) - Insert the contents of a paste buffer into the current window. - With -d, also delete the paste buffer from the stack. When out- - put, any linefeed (LF) characters in the paste buffer are - replaced with carriage returns (CR). This translation may be - disabled with the -r flag. - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock the server until a password is entered. - - server-info - (alias: info) - Show server information and terminal details. - - set-password [-c] password - (alias: pass) - Set the server password. If the -c option is given, a pre- - encrypted password may be specified. By default, the password is - blank, thus any entered password will be accepted when unlocking - the server (see the lock-server command). To prevent variable - expansion when an encrypted password is read from a configuration - file, enclose it in single quotes ('). - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.1.txt b/manual/1.1.txt deleted file mode 100644 index ca93ff6708..0000000000 --- a/manual/1.1.txt +++ /dev/null @@ -1,1313 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquv] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -q Prevent the server sending various informational messages, - for example when window flags are altered. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - Some of the default key bindings are: - - c Create a new window. - d Detach the current client. - l Move to the previously selected window. - n Change to the next window. - p Change to the previous window. - & Kill the current window. - , Rename the current window. - ? List all key bindings. - - A complete list may be obtained with the list-keys command (bound to '?' - by default). Key bindings may be changed with the bind-key and - unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Examples include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - -CLIENTS AND SESSIONS - The following commands are available: - - attach-session [-d] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - lock-client [-t target-client] - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [command] - (alias: new) - Create a new session with name session-name. The new session is - attached to the current terminal unless -d is given. window-name - and command are the name of and command to execute in the initial - window. - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or command are invalid if -t is used. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The others are: - - output mode - This is entered when a command which produces output, such as - list-keys, is executed from a key binding. - - copy mode - This permits a section of a window or its history to be copied to - a paste buffer for later insertion into another window. This - mode is entered with the copy-mode command, bound to '[' by - default. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete to end of line D C-k - End of line $ C-e - Goto line : g - Half page down C-d M-Down - Half page up C-u M-Up - Next page C-f Page down - Next word w M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Quit mode q Escape - Scroll down C-Down or J C-Down - Scroll up C-Up or K C-Up - Search again n n - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Transpose chars C-t - - These key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the window-choose command) or in output mode; and vi-copy - and emacs-copy used in copy mode. The tables may be viewed with the - list-keys command and keys modified or removed with bind-key and - unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The mode commands are as follows: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - up-pane and down-pane commands and the rotate-window and swap-pane com- - mands may be used to swap panes without changing their position. Panes - are numbered beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'C-space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time and display-panes-colour session - options. While the indicator is on screen, a pane may be - selected with the '0' to '9' keys. - - down-pane [-t target-pane] - (alias: downp) - Change the active pane to the next pane (higher index). - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-t target-window] - (alias: lsp) - List the panes in the current window or in target-window. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - move-window [-d] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-dk] [-n window-name] [-t target-window] [command] - (alias: neww) - Create a new window. If -d is given, the session does not make - the new window the current window. target-window represents the - window to be created; if the target already exists an error is - shown, unless the -k flag is used, in which case it is destroyed. - command is the command to execute. If command is not specified, - the default command is used. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before command is executed. If no - command is given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output' - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-window [-k] [-t target-window] [command] - (alias: respawnw) - Reactive a window in which the command has exited (see the - remain-on-exit window option). If command is not given, the com- - mand used when the window was created is executed. The window - must be already inactive, unless -k is given, in which case any - existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last layout used (if any) is reapplied. - - select-pane [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - split-window [-dhv] [-l size | -p percentage] [-t target-window] - [command] - (alias: splitw) - Creates a new pane by splitting the active pane: -h does a hori- - zontal split and -v a vertical split; if neither is specified, -v - is assumed. The -l and -p options specify the size of the new - window in lines (for vertical split) or in cells (for horizontal - split), or as a percentage, respectively. All other options have - the same meaning as in the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - - up-pane [-t target-pane] - (alias: upp) - Change the active pane to the previous pane (lower index). - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: BSpace, - BTab, DC (Delete), End, Enter, Escape, F1 to F20, Home, IC (Insert), - NPage (Page Up), PPage (Page Down), Space, and Tab. Note that to bind - the '"' or ''' keys, quotation marks are necessary, for example: - - bind-key '"' split-window - bind-key "'" select-prompt - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are enclosed - in square brackets. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. If multi- - ple prefix keys are configured, only the first is sent. - - unbind-key [-cn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are two types of option: session options - and window options. - - Each individual session may have a set of session options, and there is a - separate set of global session options. Sessions which do not have a - particular option configured inherit the value from the global session - options. Session options are set or unset with the set-option command - and may be listed with the show-options command. The available session - options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agu] [-t target-session] option value - (alias: set) - Set a session option. With -a, and if the option expects a - string, value is appended to the existing setting. If -g is - specified, the global session option is set. The -u flag unsets - an option, so a session inherits the option from the global - options - it is not possible to unset a global option. - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command command - Set the command used for new windows (if not specified - when the window is created) to command, which may be any - sh(1) command. The default is an empty string, which - instructs tmux to create a login shell using the value of - the default-shell option. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is the current working directory when the server is - started. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - display-panes-colour colour - Set the colour used for the display-panes command. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either default or a comma-delimited list of one or more - of: bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - palette, or default. - - message-fg colour - Set status line message foreground colour. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - prefix keys - Set the keys accepted as a prefix key. keys is a comma- - separated list of key names, each of which individually - behave as the prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. Defaults to emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(command) First line of command's output - #[attributes] Colour or attribute change - #H Hostname of local host - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(command) form executes 'command' as a shell command - and inserts the first line of its output. Note that - shell commands are only executed once at the interval - specified by the status-interval option: if the status - line is redrawn in the meantime, the previous result is - used. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the date and time will be shown. As with - status-left, string will be passed to strftime(3), char- - acter pairs are replaced, and UTF-8 is dependent on the - status-utf8 option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY WINDOWID SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-width width - main-pane-height height - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. Key bindings default to emacs. - - mode-mouse [on | off] - Mouse state in modes. If on, tmux will respond to mouse - clicks by moving the cursor in copy mode or selecting an - option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window, except for panes that are not in output - mode. - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. - - show-options [-g] [-t target-session] - (alias: show) - Show the session options for target session, or the global ses- - sion options with -g. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged with - the session environment overriding any variable present in both. This is - the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. The - window list shows the index, name and (if any) flag of the windows - present in the current session in ascending numerical order. The flag is - one of the following symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command. If -p is given, prompts is - a comma-separated list of prompts which are displayed in order; - otherwise a single prompt is displayed, constructed from template - if it is present, or ':' if not. Before the command is executed, - the first occurrence of the string '%%' and all occurrences of - '%1' are replaced by the response to the first prompt, the second - '%%' and all '%2' are replaced with the response to the second - prompt, and so on for further prompts. Up to nine prompt - responses may be replaced ('%1' to '%9'). - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - display-message [-t target-client] [message] - (alias: display) - Display a message (see the status-left option below) in the sta- - tus line. - - select-prompt [-t target-client] - Open a prompt inside target-client allowing a window index to be - entered interactively. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-t target-window] - (alias: pasteb) - Insert the contents of a paste buffer into the current window. - With -d, also delete the paste buffer from the stack. When out- - put, any linefeed (LF) characters in the paste buffer are - replaced with carriage returns (CR). This translation may be - disabled with the -r flag. - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell command - (alias: run) - Execute command in the background without creating a window. - After the command finishes, any output to stdout is displayed in - output mode. If command doesn't return success, the exit status - is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.2.txt b/manual/1.2.txt deleted file mode 100644 index c76e1623b5..0000000000 --- a/manual/1.2.txt +++ /dev/null @@ -1,1473 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquv] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - Some of the default key bindings are: - - c Create a new window. - d Detach the current client. - l Move to the previously selected window. - n Change to the next window. - p Change to the previous window. - & Kill the current window. - , Rename the current window. - ? List all key bindings. - - A complete list may be obtained with the list-keys command (bound to '?' - by default). Key bindings may be changed with the bind-key and - unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. The special character '!' uses the last - (previously current) window, or '+' and '-' are the next window or the - previous window by number. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. One of the strings top, bottom, - left, right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one of more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client command have any - effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - lock-client [-t target-client] - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The others are: - - output mode - This is entered when a command which produces output, such as - list-keys, is executed from a key binding. - - copy mode - This permits a section of a window or its history to be copied to - a paste buffer for later insertion into another window. This - mode is entered with the copy-mode command, bound to '[' by - default. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators window option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command) or in output mode; and vi-copy - and emacs-copy used in copy mode. The tables may be viewed with the - list-keys command and keys modified or removed with bind-key and - unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The mode commands are as follows: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - up-pane and down-pane commands and the rotate-window and swap-pane com- - mands may be used to swap panes without changing their position. Panes - are numbered beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - capture-pane [-b buffer-index] [-t target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - down-pane [-t target-pane] - (alias: downp) - Change the active pane to the next pane (higher index). - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - join-pane [-dhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-t target-window] - (alias: lsp) - List the panes in the current window or in target-window. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - move-window [-d] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-dk] [-n window-name] [-t target-window] [shell-command] - (alias: neww) - Create a new window. If -d is given, the session does not make - the new window the current window. target-window represents the - window to be created; if the target already exists an error is - shown, unless the -k flag is used, in which case it is destroyed. - shell-command is the command to execute. If shell-command is not - specified, the value of the default-command option is used. - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. If no - shell-command is given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output' - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last layout used (if any) is reapplied. - - select-pane [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - split-window [-dhv] [-l size | -p percentage] [-t target-pane] - [shell-command] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - - up-pane [-t target-pane] - (alias: upp) - Change the active pane to the previous pane (lower index). - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage (Page Up), PPage (Page Down), Space, and - Tab. Note that to bind the '"' or ''' keys, quotation marks are neces- - sary, for example: - - bind-key '"' split-window - bind-key "'" select-prompt - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are marked - with '(no prefix)'. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. If multi- - ple prefix keys are configured, only the first is sent. - - unbind-key [-cn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - Available window options are listed under set-window-option. - - Available server options are: - - escape-time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - quiet Enable or disable the display of various informational - messages (see also the -q command line flag). - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is the current working directory when the server is - started. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either default or a comma-delimited list of one or more - of: bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - palette, or default. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - pane-border-fg colour - - pane-border-bg colour - Set the pane border colour for panes aside from the - active pane. - - pane-active-border-fg colour - - pane-active-border-bg colour - Set the pane border colour for the currently active pane. - - prefix keys - Set the keys accepted as a prefix key. keys is a comma- - separated list of key names, each of which individually - behave as the prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. Defaults to emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #F Current window flag - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY WINDOWID SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-width width - main-pane-height height - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. Key bindings default to emacs. - - mode-mouse [on | off] - Mouse state in modes. If on, tmux will respond to mouse - clicks by moving the cursor in copy mode or selecting an - option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window, except for panes that are not in output - mode. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities to be - issued to preserve the existing window content on start - and restore it on exit. - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - word-separators string - Sets the window's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - show-options [-gsw] [-t target-session | target-window] - (alias: show) - Show the window options with -w (equivalent to - show-window-options), the server options with -s, otherwise the - session options for target session. Global session or window - options are listed if -g is used. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged with - the session environment overriding any variable present in both. This is - the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command. If -p is given, prompts is - a comma-separated list of prompts which are displayed in order; - otherwise a single prompt is displayed, constructed from template - if it is present, or ':' if not. Before the command is executed, - the first occurrence of the string '%%' and all occurrences of - '%1' are replaced by the response to the first prompt, the second - '%%' and all '%2' are replaced with the response to the second - prompt, and so on for further prompts. Up to nine prompt - responses may be replaced ('%1' to '%9'). - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - display-message [-p] [-t target-client] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is as for status-left, with the exception - that #() are not handled. - - select-prompt [-t target-client] - Open a prompt inside target-client allowing a window index to be - entered interactively. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-t target-window] - (alias: pasteb) - Insert the contents of a paste buffer into the current window. - With -d, also delete the paste buffer from the stack. When out- - put, any linefeed (LF) characters in the paste buffer are - replaced with carriage returns (CR). This translation may be - disabled with the -r flag. - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - output mode. If the command doesn't return success, the exit - status is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.3.txt b/manual/1.3.txt deleted file mode 100644 index 7397d6b191..0000000000 --- a/manual/1.3.txt +++ /dev/null @@ -1,1585 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquv] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. The special character '!' uses the last - (previously current) window, or '+' and '-' are the next window or the - previous window by number. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one of more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client command have any - effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump backward F F - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators window option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - capture-pane [-b buffer-index] [-t target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - join-pane [-dhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-t target-window] - (alias: lsp) - List the panes in the current window or in target-window. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - move-window [-dk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-adk] [-n window-name] [-t target-window] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left command. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. - - select-pane [-DLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - split-window [-dhv] [-l size | -p percentage] [-t target-pane] - [shell-command] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage (Page Up), PPage (Page Down), Space, and - Tab. Note that to bind the '"' or ''' keys, quotation marks are neces- - sary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are marked - with '(no prefix)'. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. If multi- - ple prefix keys are configured, only the first is sent. - - unbind-key [-cn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - Available window options are listed under set-window-option. - - Available server options are: - - detach-on-destroy - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - escape-time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - quiet Enable or disable the display of various informational - messages (see also the -q command line flag). - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is empty, which means to use the working directory of the - shell from which the server was started if it is avail- - able or the user's home if not. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - palette, or default. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - pane-border-fg colour - - pane-border-bg colour - Set the pane border colour for panes aside from the - active pane. - - pane-active-border-fg colour - - pane-active-border-bg colour - Set the pane border colour for the currently active pane. - - prefix keys - Set the keys accepted as a prefix key. keys is a comma- - separated list of key names, each of which individually - behave as the prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. Defaults to emacs. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #F Current window flag - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - The window title (#T) is the title set by the program - running within the window using the OSC title setting - sequence, for example: - - $ printf '\033]2;My Title\033\\' - - When a window is first created, its title is the host- - name. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY WINDOWID SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-width width - main-pane-height height - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. Key bindings default to emacs. - - mode-mouse [on | off] - Mouse state in modes. If on, the mouse may be used to - copy a selection by dragging in copy mode, or to select - an option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities to be - issued to preserve the existing window content on start - and restore it on exit. - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-alert-attr attributes - Set status line attributes for windows which have an - alert (bell, activity or content). - - window-status-alert-bg colour - Set status line background colour for windows with an - alert. - - window-status-alert-fg colour - Set status line foreground colour for windows with an - alert. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - word-separators string - Sets the window's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - show-options [-gsw] [-t target-session | target-window] - (alias: show) - Show the window options with -w (equivalent to - show-window-options), the server options with -s, otherwise the - session options for target session. Global session or window - options are listed if -g is used. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged with - the session environment overriding any variable present in both. This is - the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command. If -p is given, prompts is - a comma-separated list of prompts which are displayed in order; - otherwise a single prompt is displayed, constructed from template - if it is present, or ':' if not. Before the command is executed, - the first occurrence of the string '%%' and all occurrences of - '%1' are replaced by the response to the first prompt, the second - '%%' and all '%2' are replaced with the response to the second - prompt, and so on for further prompts. Up to nine prompt - responses may be replaced ('%1' to '%9'). - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - display-message [-p] [-t target-client] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is as for status-left, with the exception - that #() are not handled. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. This command works only from inside tmux. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - copy mode. If the command doesn't return success, the exit sta- - tus is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.4.txt b/manual/1.4.txt deleted file mode 100644 index 534902a01e..0000000000 --- a/manual/1.4.txt +++ /dev/null @@ -1,1633 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquvV] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -L socket-name - tmux stores the server socket in a directory under /tmp; - the default socket is named default. This option allows a - different socket name to be specified, allowing several - independent tmux servers to be run. Unlike -S a full path - is not necessary: the sockets are all created in the same - directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - -V Report the tmux version. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - ; Move to the previously active pane. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - L Switch the attached client back to the last session. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. The special character '!' uses the last - (previously current) window, or '+' and '-' are the next window or the - previous window by number. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one or more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client command have any - effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - detach-client [-t target-client] - (alias: detach) - Detach the current client if bound to a key, or the specified - client with -t. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients - (alias: lsc) - List all clients attached to the server. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-c target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-lnp] [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. If -l, -n or -p is used, the client is moved to - the last, next or previous session respectively. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump backward F F - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators window option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - capture-pane [-b buffer-index] [-t target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - join-pane [-dhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-pane [-t target-window] - (alias: lastp) - Select the last (previously selected) pane. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-t target-window] - (alias: lsp) - List the panes in the current window or in target-window. - - list-windows [-t target-session] - (alias: lsw) - List windows in the current session or in target-session. - - move-window [-dk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-adk] [-n window-name] [-t target-window] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left command. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. - - select-pane [-DLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. - - select-window [-t target-window] - (alias: selectw) - Select the window at target-window. - - split-window [-dhv] [-l size | -p percentage] [-t target-pane] - [shell-command] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage (Page Up), PPage (Page Down), Space, and - Tab. Note that to bind the '"' or ''' keys, quotation marks are neces- - sary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are marked - with '(no prefix)'. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. If multi- - ple prefix keys are configured, only the first is sent. - - unbind-key [-acn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. If -a is - present, all key bindings are removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - Available window options are listed under set-window-option. - - Available server options are: - - escape-time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - exit-unattached - If enabled, the server will exit when there are no - attached clients, rather than when there are no attached - sessions. - - quiet Enable or disable the display of various informational - messages (see also the -q command line flag). - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - buffer-limit number - Set the number of buffers kept for each session; as new - buffers are added to the top of the stack, old ones are - removed from the bottom if necessary to maintain this - maximum length. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is empty, which means to use the working directory of the - shell from which the server was started if it is avail- - able or the user's home if not. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - destroy-unattached - If enabled and the session is no longer attached to any - clients, it is destroyed. - - detach-on-destroy - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - palette, or default. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - pane-active-border-bg colour - - pane-active-border-fg colour - Set the pane border colour for the currently active pane. - - pane-border-bg colour - - pane-border-fg colour - Set the pane border colour for panes aside from the - active pane. - - prefix keys - Set the keys accepted as a prefix key. keys is a comma- - separated list of key names, each of which individually - behave as the prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. The default is emacs, - unless the VISUAL or EDITOR environment variables are set - and contain the string 'vi'. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #F Current window flag - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - The window title (#T) is the title set by the program - running within the window using the OSC title setting - sequence, for example: - - $ printf '\033]2;My Title\033\\' - - When a window is first created, its title is the host- - name. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION WINDOWID XAU- - THORITY". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - visual-silence [on | off] - If monitor-silence is enabled, prints a message after the - interval has expired on a given window. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities to be - issued to preserve the existing window content on start - and restore it on exit. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-height height - main-pane-width width - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. As with the status-keys option, the default is - emacs, unless VISUAL or EDITOR contains 'vi'. - - mode-mouse [on | off] - Mouse state in modes. If on, the mouse may be used to - copy a selection by dragging in copy mode, or to select - an option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - monitor-silence [interval] - Monitor for silence (no activity) in the window within - interval seconds. Windows that have been silent for the - interval are highlighted in the status line. An interval - of zero disables the monitoring. - - other-pane-height height - Set the height of the other panes (not the main pane) in - the main-horizontal layout. If this option is set to 0 - (the default), it will have no effect. If both the - main-pane-height and other-pane-height options are set, - the main pane will grow taller to make the other panes - the specified height, but will never shrink to do so. - - other-pane-width width - Like other-pane-height, but set the width of other panes - in the main-vertical layout. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-alert-attr attributes - Set status line attributes for windows which have an - alert (bell, activity or content). - - window-status-alert-bg colour - Set status line background colour for windows with an - alert. - - window-status-alert-fg colour - Set status line foreground colour for windows with an - alert. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - word-separators string - Sets the window's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - show-options [-gsw] [-t target-session | target-window] - (alias: show) - Show the window options with -w (equivalent to - show-window-options), the server options with -s, otherwise the - session options for target session. Global session or window - options are listed if -g is used. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged. If a - variable exists in both, the value from the session environment is used. - The result is the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - ~ The window has been silent for the monitor-silence - interval. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. If template is - specified, it is used as the command. If -p is given, prompts is - a comma-separated list of prompts which are displayed in order; - otherwise a single prompt is displayed, constructed from template - if it is present, or ':' if not. Before the command is executed, - the first occurrence of the string '%%' and all occurrences of - '%1' are replaced by the response to the first prompt, the second - '%%' and all '%2' are replaced with the response to the second - prompt, and so on for further prompts. Up to nine prompt - responses may be replaced ('%1' to '%9'). - - confirm-before [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. This command - works only from inside tmux. - - display-message [-p] [-t target-client] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is as for status-left, with the exception - that #() are not handled. - -BUFFERS - tmux maintains a stack of paste buffers for each session. Up to the - value of the buffer-limit option are kept; when a new buffer is added, - the buffer at the bottom of the stack is removed. Buffers may be added - using copy-mode or the set-buffer command, and pasted into a window using - the paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. This command works only from inside tmux. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - copy-buffer [-a src-index] [-b dst-index] [-s src-session] [-t - dst-session] - (alias: copyb) - Copy a session paste buffer to another session. If no sessions - are specified, the current one is used instead. - - delete-buffer [-b buffer-index] [-t target-session] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-t target-session] - (alias: lsb) - List the buffers in the given session. - - load-buffer [-b buffer-index] [-t target-session] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). - - save-buffer [-a] [-b buffer-index] [-t target-session] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] [-t target-session] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] [-t target-session] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - copy mode. If the command doesn't return success, the exit sta- - tus is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.5.txt b/manual/1.5.txt deleted file mode 100644 index 6e96fa9ae9..0000000000 --- a/manual/1.5.txt +++ /dev/null @@ -1,1741 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquvV] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -L socket-name - tmux stores the server socket in a directory under /tmp (or - TMPDIR if set); the default socket is named default. This - option allows a different socket name to be specified, - allowing several independent tmux servers to be run. - Unlike -S a full path is not necessary: the sockets are all - created in the same directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - -V Report the tmux version. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - $ Rename the current session. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - ; Move to the previously active pane. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - L Switch the attached client back to the last session. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. The special character '!' uses the last - (previously current) window, or '+' and '-' are the next window or the - previous window by number. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - tmux also gives each pane created in a server an identifier consisting of - a '%' and a number, starting from zero. A pane's identifier is unique - for the life of the tmux server and is passed to the child process of the - pane in the TMUX_PANE environment variable. It may be used alone to tar- - get a pane or the window containing it. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right. A literal semi- - colon may be included by escaping it with a backslash (for example, when - specifying a command sequence to bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one or more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client command have any - effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - The target-session rules for attach-session are slightly - adjusted: if tmux needs to select the most recently used session, - it will prefer the most recently used unattached session. - - detach-client [-P] [-s target-session] [-t target-client] - (alias: detach) - Detach the current client if bound to a key, the client specified - with -t, or all clients currently attached to to the session - specified by -s. If -P is given, send SIGHUP to the parent - process of the client, typically causing it to exit. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients [-t target-session] - (alias: lsc) - List all clients attached to the server. If target-session is - specified, list only clients connected to that session. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions - (alias: ls) - List all sessions managed by the server. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [-x width] [-y height] [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. If -d is used, -x and - -y specify the size of the initial window (80 by 24 if not - given). - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-t target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-lnp] [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. If -l, -n or -p is used, the client is moved to - the last, next or previous session respectively. - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete/Copy to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump backward F F - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators window option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - capture-pane [-b buffer-index] [-E end-line] [-S start-line] [-t - target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - -S and -E specify the starting and ending line numbers, zero is - the first line of the visible pane and negative numbers are lines - in the history. The default is to capture only the visible con- - tents of the pane. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - join-pane [-dhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-pane [-t target-window] - (alias: lastp) - Select the last (previously selected) pane. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-as] [-t target] - (alias: lsp) - If -a is given, target is ignored and all panes on the server are - listed. If -s is given, target is a session (or the current ses- - sion). If neither is given, target is a window (or the current - window). - - list-windows [-a] [-t target-session] - (alias: lsw) - If -a is given, list all windows on the server. Otherwise, list - windows in the current session or in target-session. - - move-window [-dk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-adkP] [-n window-name] [-t target-window] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - The -P option prints the location of the new window after it has - been created. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left option. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-pane [-k] [-t target-pane] [shell-command] - (alias: respawnp) - Reactivate a pane in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the pane was created is executed. The pane - must be already inactive, unless -k is given, in which case any - existing command is killed. - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-np] [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. -n and - -p are equivalent to the next-layout and previous-layout com- - mands. - - select-pane [-lDLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. -l - is the same as using the last-pane command. - - select-window [-lnp] [-t target-window] - (alias: selectw) - Select the window at target-window. -l, -n and -p are equivalent - to the last-window, next-window and previous-window commands. - - split-window [-dhvP] [-l size | -p percentage] [-t target-pane] - [shell-command] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage (Page Up), PPage (Page Down), Space, and - Tab. Note that to bind the '"' or ''' keys, quotation marks are neces- - sary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are marked - with '(no prefix)'. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. - - send-prefix [-t target-pane] - Send the prefix key to a window as if it was pressed. If multi- - ple prefix keys are configured, only the first is sent. - - unbind-key [-acn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. If -a is - present, all key bindings are removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - Available window options are listed under set-window-option. - - Available server options are: - - buffer-limit number - Set the number of buffers; as new buffers are added to - the top of the stack, old ones are removed from the bot- - tom if necessary to maintain this maximum length. - - set-clipboard [on | off] - Attempt to set the terminal clipboard content using the - \e]52;...\007 xterm(1) escape sequences. This option is - on by default if there is an Ms entry in the terminfo(5) - description for the client terminal. Note that this fea- - ture needs to be enabled in xterm(1) by setting the - resource: - - disallowedWindowOps: 20,21,SetXprop - - Or changing this property from the xterm(1) interactive - menu when required. - - escape-time time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - exit-unattached [on | off] - If enabled, the server will exit when there are no - attached clients. - - quiet [on | off] - Enable or disable the display of various informational - messages (see also the -q command line flag). - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - bell-on-alert [on | off] - If on, ring the terminal bell when an activity, content - or silence alert occurs. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-path path - Set the default working directory for processes created - from keys, or interactively from the prompt. The default - is empty, which means to use the working directory of the - shell from which the server was started if it is avail- - able or the user's home if not. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - destroy-unattached [on | off] - If enabled and the session is no longer attached to any - clients, it is destroyed. - - detach-on-destroy [on | off] - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, colour0 to colour255 from the 256-colour - set, default, or a hexadecimal RGB string such as - '#ffffff', which chooses the closest match from the - default 256-colour set. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-resize-pane [on | off] - If on, tmux captures the mouse and allows panes to be - resized by dragging on their borders. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - mouse-select-window [on | off] - If on, clicking the mouse on a window name in the status - line will select that window. - - pane-active-border-bg colour - - pane-active-border-fg colour - Set the pane border colour for the currently active pane. - - pane-border-bg colour - - pane-border-fg colour - Set the pane border colour for panes aside from the - active pane. - - prefix keys - Set the keys accepted as a prefix key. keys is a comma- - separated list of key names, each of which individually - behave as the prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - mouse-utf8 [on | off] - If enabled, request mouse input as UTF-8 on UTF-8 termi- - nals. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the window title using the \e]2;...\007 - xterm code if the terminal appears to be an xterm. This - option is off by default. Note that elinks will only - attempt to set the window title if the STY environment - variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. The default is emacs, - unless the VISUAL or EDITOR environment variables are set - and contain the string 'vi'. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #h Hostname of local host without - the domain name - #F Current window flag - #I Current window index - #P Current pane index - #S Session name - #T Current window title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - The window title (#T) is the title set by the program - running within the window using the OSC title setting - sequence, for example: - - $ printf '\033]2;My Title\033\\' - - When a window is first created, its title is the host- - name. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256,xterm*:XT" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION WINDOWID XAU- - THORITY". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - visual-silence [on | off] - If monitor-silence is enabled, prints a message after the - interval has expired on a given window. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities. The - alternate screen feature preserves the contents of the - window when an interactive application starts and - restores it on exit, so that any output visible before - the application starts reappears unchanged after it - exits. The default is on. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window. It may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-height height - main-pane-width width - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. As with the status-keys option, the default is - emacs, unless VISUAL or EDITOR contains 'vi'. - - mode-mouse [on | off] - Mouse state in modes. If on, the mouse may be used to - enter copy mode and copy a selection by dragging, to - enter copy mode and scroll with the mouse wheel, or to - select an option in choice mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - monitor-silence [interval] - Monitor for silence (no activity) in the window within - interval seconds. Windows that have been silent for the - interval are highlighted in the status line. An interval - of zero disables the monitoring. - - other-pane-height height - Set the height of the other panes (not the main pane) in - the main-horizontal layout. If this option is set to 0 - (the default), it will have no effect. If both the - main-pane-height and other-pane-height options are set, - the main pane will grow taller to make the other panes - the specified height, but will never shrink to do so. - - other-pane-width width - Like other-pane-height, but set the width of other panes - in the main-vertical layout. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-alert-attr attributes - Set status line attributes for windows which have an - alert (bell, activity or content). - - window-status-alert-bg colour - Set status line background colour for windows with an - alert. - - window-status-alert-fg colour - Set status line foreground colour for windows with an - alert. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - word-separators string - Sets the window's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - show-options [-gsw] [-t target-session | target-window] - (alias: show) - Show the window options with -w (equivalent to - show-window-options), the server options with -s, otherwise the - session options for target session. Global session or window - options are listed if -g is used. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged. If a - variable exists in both, the value from the session environment is used. - The result is the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the current window title in double quotes; and the time and date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - ~ The window has been silent for the monitor-silence - interval. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-I inputs] [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. - - If template is specified, it is used as the command. If present, - -I is a comma-separated list of the initial text for each prompt. - If -p is given, prompts is a comma-separated list of prompts - which are displayed in order; otherwise a single prompt is dis- - played, constructed from template if it is present, or ':' if - not. - - Both inputs and prompts may contain the special character - sequences supported by the status-left option. - - Before the command is executed, the first occurrence of the - string '%%' and all occurrences of '%1' are replaced by the - response to the first prompt, the second '%%' and all '%2' are - replaced with the response to the second prompt, and so on for - further prompts. Up to nine prompt responses may be replaced - ('%1' to '%9'). - - confirm-before [-p prompt] [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. If -p is given, - prompt is the prompt to display; otherwise a prompt is con- - structed from command. It may contain the special character - sequences supported by the status-left option. - - This command works only from inside tmux. - - display-message [-p] [-c target-client] [-t target-pane] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is as for status-left, with the exception - that #() are not handled; information is taken from target-pane - if -t is given, otherwise the active pane for the session - attached to target-client. - -BUFFERS - tmux maintains a stack of paste buffers. Up to the value of the - buffer-limit option are kept; when a new buffer is added, the buffer at - the bottom of the stack is removed. Buffers may be added using copy-mode - or the set-buffer command, and pasted into a window using the - paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. This command works only from inside tmux. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - delete-buffer [-b buffer-index] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers - (alias: lsb) - List the global buffers. - - load-buffer [-b buffer-index] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). - - save-buffer [-a] [-b buffer-index] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command - (alias: if) - Execute command if shell-command returns success. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - copy mode. If the command doesn't return success, the exit sta- - tus is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -TERMINFO EXTENSIONS - tmux understands some extensions to terminfo(5): - - Cc, Cr Set the cursor colour. The first takes a single string argument - and is used to set the colour; the second takes no arguments and - restores the default cursor colour. If set, a sequence such as - this may be used to change the cursor colour from inside tmux: - - $ printf '\033]12;red\033\\' - - Cs, Csr - Change the cursor style. If set, a sequence such as this may be - used to change the cursor to an underline: - - $ printf '\033[4 q' - - If Csr is set, it will be used to reset the cursor style instead - of Cs. - - Ms This sequence can be used by tmux to store the current buffer in - the host terminal's selection (clipboard). See the set-clipboard - option above and the xterm(1) man page. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.6.txt b/manual/1.6.txt deleted file mode 100644 index b3ecbd3985..0000000000 --- a/manual/1.6.txt +++ /dev/null @@ -1,1891 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquvV] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -L socket-name - tmux stores the server socket in a directory under /tmp (or - TMPDIR if set); the default socket is named default. This - option allows a different socket name to be specified, - allowing several independent tmux servers to be run. - Unlike -S a full path is not necessary: the sockets are all - created in the same directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - -V Report the tmux version. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - $ Rename the current session. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - ; Move to the previously active pane. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - L Switch the attached client back to the last session. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as an exact window - name, such as mysession:mywindow; then as an fnmatch(3) pattern or the - start of a window name, such as mysession:mywin* or mysession:mywin. An - empty window name specifies the next unused index if appropriate (for - example the new-window and link-window commands) otherwise the current - window in session is chosen. The special character '!' uses the last - (previously current) window, or '+' and '-' are the next window or the - previous window by number. When the argument does not contain a colon, - tmux first attempts to parse it as window; if that fails, an attempt is - made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - tmux also gives each pane created in a server an identifier consisting of - a '%' and a number, starting from zero. A pane's identifier is unique - for the life of the tmux server and is passed to the child process of the - pane in the TMUX_PANE environment variable. It may be used alone to tar- - get a pane or the window containing it. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right and lines ending - with a backslash continue on to the next line. A literal semicolon may - be included by escaping it with a backslash (for example, when specifying - a command sequence to bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key R source-file ~/.tmux.conf \; \ - display-message "source-file done" - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one or more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client or switch-client - commands have any effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - The target-session rules for attach-session are slightly - adjusted: if tmux needs to select the most recently used session, - it will prefer the most recently used unattached session. - - detach-client [-P] [-s target-session] [-t target-client] - (alias: detach) - Detach the current client if bound to a key, the client specified - with -t, or all clients currently attached to the session speci- - fied by -s. If -P is given, send SIGHUP to the parent process of - the client, typically causing it to exit. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session [-t target-session] - Destroy the given session, closing any windows linked to it and - no other sessions, and detaching all clients attached to it. - - list-clients [-F format] [-t target-session] - (alias: lsc) - List all clients attached to the server. For the meaning of the - -F flag, see the FORMATS section. If target-session is speci- - fied, list only clients connected to that session. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions [-F format] - (alias: ls) - List all sessions managed by the server. For the meaning of the - -F flag, see the FORMATS section. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [-x width] [-y height] [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. If -d is used, -x and - -y specify the size of the initial window (80 by 24 if not - given). - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-S] [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. If -S is specified, only update the - client's status bar. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-t target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-lnpr] [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. If -l, -n or -p is used, the client is moved to - the last, next or previous session respectively. -r toggles - whether a client is read-only (see the attach-session command). - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete/Copy to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump to forward t - Jump backward F F - Jump to backward T - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators session option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - When copying the selection, the repeat count indicates the buffer index - to replace, if used. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-d] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. - - capture-pane [-b buffer-index] [-E end-line] [-S start-line] [-t - target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - -S and -E specify the starting and ending line numbers, zero is - the first line of the visible pane and negative numbers are lines - in the history. The default is to capture only the visible con- - tents of the pane. - - choose-client [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. This command works only from inside - tmux. - - choose-session [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. This command works only from inside tmux. - - choose-window [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. This command works only from - inside tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). If only one win- - dow is matched, it'll be automatically selected, otherwise a - choice list is shown. This command only works from inside tmux. - - join-pane [-dhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. - - last-pane [-t target-window] - (alias: lastp) - Select the last (previously selected) pane. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-as] [-F format] [-t target] - (alias: lsp) - If -a is given, target is ignored and all panes on the server are - listed. If -s is given, target is a session (or the current ses- - sion). If neither is given, target is a window (or the current - window). For the meaning of the -F flag, see the FORMATS sec- - tion. - - list-windows [-a] [-F format] [-t target-session] - (alias: lsw) - If -a is given, list all windows on the server. Otherwise, list - windows in the current session or in target-session. For the - meaning of the -F flag, see the FORMATS section. - - move-window [-dk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. - - new-window [-adkP] [-n window-name] [-t target-window] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - The -P option prints the location of the new window after it has - been created. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with a bell, activity or content alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left option. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with a bell, activity or content alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-pane [-k] [-t target-pane] [shell-command] - (alias: respawnp) - Reactivate a pane in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the pane was created is executed. The pane - must be already inactive, unless -k is given, in which case any - existing command is killed. - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-np] [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. -n and - -p are equivalent to the next-layout and previous-layout com- - mands. - - select-pane [-lDLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. -l - is the same as using the last-pane command. - - select-window [-lnp] [-t target-window] - (alias: selectw) - Select the window at target-window. -l, -n and -p are equivalent - to the last-window, next-window and previous-window commands. - - split-window [-dhvP] [-l size | -p percentage] [-t target-pane] - [shell-command] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage/PageDown/PgDn, PPage/PageUp/PgUp, Space, - and Tab. Note that to bind the '"' or ''' keys, quotation marks are nec- - essary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - Keys bound without the prefix key (see bind-key -n) are marked - with '(no prefix)'. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys -R [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. All - arguments are sent sequentially from first to last. The -R flag - causes the terminal state to be reset. - - send-prefix [-2] [-t target-pane] - Send the prefix key, or with -2 the secondary prefix key, to a - window as if it was pressed. - - unbind-key [-acn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. If -a is - present, all key bindings are removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - Available window options are listed under set-window-option. - - Available server options are: - - buffer-limit number - Set the number of buffers; as new buffers are added to - the top of the stack, old ones are removed from the bot- - tom if necessary to maintain this maximum length. - - escape-time time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - exit-unattached [on | off] - If enabled, the server will exit when there are no - attached clients. - - quiet [on | off] - Enable or disable the display of various informational - messages (see also the -q command line flag). - - set-clipboard [on | off] - Attempt to set the terminal clipboard content using the - \e]52;...\007 xterm(1) escape sequences. This option is - on by default if there is an Ms entry in the terminfo(5) - description for the client terminal. Note that this fea- - ture needs to be enabled in xterm(1) by setting the - resource: - - disallowedWindowOps: 20,21,SetXprop - - Or changing this property from the xterm(1) interactive - menu when required. - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - bell-on-alert [on | off] - If on, ring the terminal bell when an activity, content - or silence alert occurs. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-path path - Set the default working directory for new panes. If - empty (the default), the working directory is determined - from the process running in the active pane, from the - command line environment or from the working directory - where the session was created. If path is "$HOME" or - "~", the value of the HOME environment variable is used. - If path is ".", the working directory when tmux was - started is used. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - destroy-unattached [on | off] - If enabled and the session is no longer attached to any - clients, it is destroyed. - - detach-on-destroy [on | off] - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, aixterm bright variants (if supported: - brightred, brightgreen, and so on), colour0 to colour255 - from the 256-colour set, default, or a hexadecimal RGB - string such as '#ffffff', which chooses the closest match - from the default 256-colour set. - - message-command-attr attributes - Set status line message attributes when in command mode. - - message-command-bg colour - Set status line message background colour when in command - mode. - - message-command-fg colour - Set status line message foreground colour when in command - mode. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-resize-pane [on | off] - If on, tmux captures the mouse and allows panes to be - resized by dragging on their borders. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - mouse-select-window [on | off] - If on, clicking the mouse on a window name in the status - line will select that window. - - mouse-utf8 [on | off] - If enabled, request mouse input as UTF-8 on UTF-8 termi- - nals. - - pane-active-border-bg colour - - pane-active-border-fg colour - Set the pane border colour for the currently active pane. - - pane-border-bg colour - - pane-border-fg colour - Set the pane border colour for panes aside from the - active pane. - - prefix key - Set the key accepted as a prefix key. - - prefix2 key - Set a secondary key accepted as a prefix key. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the client terminal title using the tsl - and fsl terminfo(5) entries if they exist. tmux automat- - ically sets these to the \e]2;...\007 sequence if the - terminal appears to be an xterm. This option is off by - default. Note that elinks will only attempt to set the - window title if the STY environment variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. The default is emacs, - unless the VISUAL or EDITOR environment variables are set - and contain the string 'vi'. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #h Hostname of local host without - the domain name - #F Current window flag - #I Current window index - #P Current pane index - #S Session name - #T Current pane title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - For details on how the names and titles can be set see - the NAMES AND TITLES section. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256,xterm*:XT" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION WINDOWID XAU- - THORITY". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - visual-silence [on | off] - If monitor-silence is enabled, prints a message after the - interval has expired on a given window. - - word-separators string - Sets the session's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - set-window-option [-agu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g and -u flags work similarly to - the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - allow-rename [on | off] - Allow programs to change the window name using a terminal - escape sequence (\033k...\033\\). The default is on. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities. The - alternate screen feature preserves the contents of the - window when an interactive application starts and - restores it on exit, so that any output visible before - the application starts reappears unchanged after it - exits. The default is on. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window, or with a terminal escape sequence. It - may be switched off globally with: - - set-window-option -g automatic-rename off - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-height height - main-pane-width width - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. As with the status-keys option, the default is - emacs, unless VISUAL or EDITOR contains 'vi'. - - mode-mouse [on | off | copy-mode] - Mouse state in modes. If on, the mouse may be used to - enter copy mode and copy a selection by dragging, to - enter copy mode and scroll with the mouse wheel, or to - select an option in choice mode. If set to copy-mode, - the mouse behaves as set to on, but cannot be used to - enter copy mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - monitor-silence [interval] - Monitor for silence (no activity) in the window within - interval seconds. Windows that have been silent for the - interval are highlighted in the status line. An interval - of zero disables the monitoring. - - other-pane-height height - Set the height of the other panes (not the main pane) in - the main-horizontal layout. If this option is set to 0 - (the default), it will have no effect. If both the - main-pane-height and other-pane-height options are set, - the main pane will grow taller to make the other panes - the specified height, but will never shrink to do so. - - other-pane-width width - Like other-pane-height, but set the width of other panes - in the main-vertical layout. - - pane-base-index index - Like base-index, but set the starting index for pane num- - bers. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-bell-attr attributes - Set status line attributes for windows which have a bell - alert. - - window-status-bell-bg colour - Set status line background colour for windows with a bell - alert. - - window-status-bell-fg colour - Set status line foreground colour for windows with a bell - alert. - - window-status-content-attr attributes - Set status line attributes for windows which have a con- - tent alert. - - window-status-content-bg colour - Set status line background colour for windows with a con- - tent alert. - - window-status-content-fg colour - Set status line foreground colour for windows with a con- - tent alert. - - window-status-activity-attr attributes - Set status line attributes for windows which have an - activity (or silence) alert. - - window-status-activity-bg colour - Set status line background colour for windows with an - activity alert. - - window-status-activity-fg colour - Set status line foreground colour for windows with an - activity alert. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - show-options [-gsw] [-t target-session | target-window] - (alias: show) - Show the window options with -w (equivalent to - show-window-options), the server options with -s, otherwise the - session options for target session. Global session or window - options are listed if -g is used. - - show-window-options [-g] [-t target-window] - (alias: showw) - List the window options for target-window, or the global window - options if -g is used. - -FORMATS - The list-clients, list-sessions, list-windows and list-panes commands - accept the -F flag with a format argument. This is a string which con- - trols the output format of the command. Special character sequences are - replaced as documented under the status-left option and an additional - long form is accepted. Replacement variables are enclosed in '#{' and - '}', for example '#{session_name}' is equivalent to '#S'. Conditionals - are also accepted by prefixing with '?' and separating two alternatives - with a comma; if the specified variable exists and is not zero, the first - alternative is chosen, otherwise the second is used. For example - '#{?session_attached,attached,not attached}' will include the string - 'attached' if the session is attached and the string 'not attached' if it - is unattached. - - The following variables are available, where appropriate: - - Variable name Replaced with - client_activity Integer time client last had activity - client_activity_string String time client last had activity - client_created Integer time client created - client_created_string String time client created - client_cwd Working directory of client - client_height Height of client - client_readonly 1 if client is readonly - client_termname Terminal name of client - client_tty Pseudo terminal of client - client_utf8 1 if client supports utf8 - client_width Width of client - host Hostname of local host - line Line number in the list - pane_active 1 if active pane - pane_dead 1 if pane is dead - pane_height Height of pane - pane_id Unique pane id - pane_pid PID of first process in pane - pane_start_command Command pane started with - pane_start_path Path pane started with - pane_title Title of pane - pane_tty Pseudo terminal of pane - pane_width Width of pane - session_attached 1 if session attached - session_created Integer time session created - session_created_string String time session created - session_group Number of session group - session_grouped 1 if session in a group - session_height Height of session - session_name Name of session - session_width Width of session - session_windows Number of windows in session - window_active 1 if window active - window_flags Window flags - window_height Height of window - window_index Index of window - window_layout Window layout description - window_name Name of window - window_width Width of window - -NAMES AND TITLES - tmux distinguishes between names and titles. Windows and sessions have - names, which may be used to specify them in targets and are displayed in - the status line and various lists: the name is the tmux identifier for a - window or session. Only panes have titles. A pane's title is typically - set by the program running inside the pane and is not modified by tmux. - It is the same mechanism used to set for example the xterm(1) window - title in an X(7) window manager. Windows themselves do not have titles - - a window's title is the title of its active pane. tmux itself may set - the title of the terminal in which the client is running, see the - set-titles option. - - A session's name is set with the new-session and rename-session commands. - A window's name is set with one of: - - 1. A command argument (such as -n for new-window or new-session). - - 2. An escape sequence: - - $ printf '\033kWINDOW_NAME\033\\' - - 3. Automatic renaming, which sets the name to the active command in - the window's active pane. See the automatic-rename option. - - When a pane is first created, its title is the hostname. A pane's title - can be set via the OSC title setting sequence, for example: - - $ printf '\033]2;My Title\033\\' - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged. If a - variable exists in both, the value from the session environment is used. - The result is the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. Variables removed from the environment are pre- - fixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the title of the active pane in double quotes; and the time and - date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - ~ The window has been silent for the monitor-silence - interval. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-I inputs] [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. - - If template is specified, it is used as the command. If present, - -I is a comma-separated list of the initial text for each prompt. - If -p is given, prompts is a comma-separated list of prompts - which are displayed in order; otherwise a single prompt is dis- - played, constructed from template if it is present, or ':' if - not. - - Both inputs and prompts may contain the special character - sequences supported by the status-left option. - - Before the command is executed, the first occurrence of the - string '%%' and all occurrences of '%1' are replaced by the - response to the first prompt, the second '%%' and all '%2' are - replaced with the response to the second prompt, and so on for - further prompts. Up to nine prompt responses may be replaced - ('%1' to '%9'). - - confirm-before [-p prompt] [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. If -p is given, - prompt is the prompt to display; otherwise a prompt is con- - structed from command. It may contain the special character - sequences supported by the status-left option. - - This command works only from inside tmux. - - display-message [-p] [-c target-client] [-t target-pane] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is as for status-left, with the exception - that #() are not handled; information is taken from target-pane - if -t is given, otherwise the active pane for the session - attached to target-client. - -BUFFERS - tmux maintains a stack of paste buffers. Up to the value of the - buffer-limit option are kept; when a new buffer is added, the buffer at - the bottom of the stack is removed. Buffers may be added using copy-mode - or the set-buffer command, and pasted into a window using the - paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. This command works only from inside tmux. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - delete-buffer [-b buffer-index] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers - (alias: lsb) - List the global buffers. - - load-buffer [-b buffer-index] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). - - save-buffer [-a] [-b buffer-index] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command [command] - (alias: if) - Execute the first command if shell-command returns success or the - second command otherwise. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - copy mode. If the command doesn't return success, the exit sta- - tus is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -TERMINFO EXTENSIONS - tmux understands some extensions to terminfo(5): - - Cc, Cr Set the cursor colour. The first takes a single string argument - and is used to set the colour; the second takes no arguments and - restores the default cursor colour. If set, a sequence such as - this may be used to change the cursor colour from inside tmux: - - $ printf '\033]12;red\033\\' - - Cs, Csr - Change the cursor style. If set, a sequence such as this may be - used to change the cursor to an underline: - - $ printf '\033[4 q' - - If Csr is set, it will be used to reset the cursor style instead - of Cs. - - Ms This sequence can be used by tmux to store the current buffer in - the host terminal's selection (clipboard). See the set-clipboard - option above and the xterm(1) man page. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.7.txt b/manual/1.7.txt deleted file mode 100644 index 1a7164201b..0000000000 --- a/manual/1.7.txt +++ /dev/null @@ -1,2010 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lquvV] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. The configuration file is a - set of tmux commands which are executed in sequence when - the server is first started. - - If a command in the configuration file fails, tmux will - report an error and exit without executing further com- - mands. - - -L socket-name - tmux stores the server socket in a directory under /tmp (or - TMPDIR if set); the default socket is named default. This - option allows a different socket name to be specified, - allowing several independent tmux servers to be run. - Unlike -S a full path is not necessary: the sockets are all - created in the same directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - -V Report the tmux version. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - $ Rename the current session. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - ; Move to the previously active pane. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - L Switch the attached client back to the last session. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is either the name of a session (as listed by the - list-sessions command) or the name of a client with the same syntax as - target-client, in which case the session attached to the client is used. - When looking for the session name, tmux initially searches for an exact - match; if none is found, the session names are checked for any for which - target-session is a prefix or for which it matches as an fnmatch(3) pat- - tern. If a single match is found, it is used as the target session; mul- - tiple matches produce an error. If a session is omitted, the current - session is used if available; if no current session is available, the - most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as a window ID, such - as @1; as an exact window name, such as mysession:mywindow; then as an - fnmatch(3) pattern or the start of a window name, such as myses- - sion:mywin* or mysession:mywin. An empty window name specifies the next - unused index if appropriate (for example the new-window and link-window - commands) otherwise the current window in session is chosen. The special - character '!' uses the last (previously current) window, or '+' and '-' - are the next window or the previous window by number. When the argument - does not contain a colon, tmux first attempts to parse it as window; if - that fails, an attempt is made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - tmux also gives each pane created in a server an identifier consisting of - a '%' and a number, starting from zero. A pane's identifier is unique - for the life of the tmux server and is passed to the child process of the - pane in the TMUX_PANE environment variable. It may be used alone to tar- - get a pane or the window containing it. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right and lines ending - with a backslash continue on to the next line, except when escaped by - another backslash. A literal semicolon may be included by escaping it - with a backslash (for example, when specifying a command sequence to - bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key R source-file ~/.tmux.conf \; \ - display-message "source-file done" - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one or more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client or switch-client - commands have any effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - The target-session rules for attach-session are slightly - adjusted: if tmux needs to select the most recently used session, - it will prefer the most recently used unattached session. - - detach-client [-P] [-a] [-s target-session] [-t target-client] - (alias: detach) - Detach the current client if bound to a key, the client specified - with -t, or all clients currently attached to the session speci- - fied by -s. The -a option kills all but the client given with - -t. If -P is given, send SIGHUP to the parent process of the - client, typically causing it to exit. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session - [-a] [-t target-session] Destroy the given session, closing any - windows linked to it and no other sessions, and detaching all - clients attached to it. If -a is given, all sessions but the - specified one is killed. - - list-clients [-F format] [-t target-session] - (alias: lsc) - List all clients attached to the server. For the meaning of the - -F flag, see the FORMATS section. If target-session is speci- - fied, list only clients connected to that session. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions [-F format] - (alias: ls) - List all sessions managed by the server. For the meaning of the - -F flag, see the FORMATS section. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-d] [-n window-name] [-s session-name] [-t target-session] - [-x width] [-y height] [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. If -d is used, -x and - -y specify the size of the initial window (80 by 24 if not - given). - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - refresh-client [-S] [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. If -S is specified, only update the - client's status bar. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-t target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-lnpr] [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. If -l, -n or -p is used, the client is moved to - the last, next or previous session respectively. -r toggles - whether a client is read-only (see the attach-session command). - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete/Copy to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump to forward t - Jump backward F F - Jump to backward T - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose chars C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators session option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - When copying the selection, the repeat count indicates the buffer index - to replace, if used. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-dP] [-F format] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. The -P option prints information - about the new window after it has been created. By default, it - uses the format '#{session_name}:#{window_index}' but a different - format may be specified with -F. - - capture-pane [-b buffer-index] [-E end-line] [-S start-line] [-t - target-pane] - (alias: capturep) - Capture the contents of a pane to the specified buffer, or a new - buffer if none is specified. - - -S and -E specify the starting and ending line numbers, zero is - the first line of the visible pane and negative numbers are lines - in the history. The default is to capture only the visible con- - tents of the pane. - - choose-client [-F format] [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. For the meaning of the -F flag, see the - FORMATS section. This command works only from inside tmux. - - choose-list [-l items] [-t target-window] [template] - Put a window into list choice mode, allowing items to be - selected. items can be a comma-separated list to display more - than one item. If an item has spaces, that entry must be quoted. - After an item is chosen, '%%' is replaced by the chosen item in - the template and the result is executed as a command. If - template is not given, "run-shell '%%'" is used. items also - accepts format specifiers. For the meaning of this see the - FORMATS section. This command works only from inside tmux. - - choose-session [-F format] [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. For the meaning of the -F flag, see the FORMATS sec- - tion. This command works only from inside tmux. - - choose-tree [-s] [-w] [-b session-template] [-c window-template] [-S - format] [-W format] [-t target-window] - Put a window into tree choice mode, where either sessions or win- - dows may be selected interactively from a list. By default, win- - dows belonging to a session are indented to show their relation- - ship to a session. - - Note that the choose-window and choose-session commands are wrap- - pers around choose-tree. - - If -s is given, will show sessions. If -w is given, will show - windows. If -b is given, will override the default session com- - mand. Note that '%%' can be used, and will be replaced with the - session name. The default option if not specified is "switch- - client -t '%%'". If -c is given, will override the default win- - dow command. Note that '%%' can be used, and will be replaced - with the session name and window index. This command will run - session-template before it. If -S is given will display the - specified format instead of the default session format. If -W is - given will display the specified format instead of the default - window format. For the meaning of the -s and -w options, see the - FORMATS section. This command only works from inside tmux. - - choose-window [-F format] [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. For the meaning of the -F flag, - see the FORMATS section. This command works only from inside - tmux. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-CNT] [-F format] [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). The flags control - matching behavior: -C matches only visible window contents, -N - matches only the window name and -T matches only the window - title. The default is -CNT. If only one window is matched, - it'll be automatically selected, otherwise a choice list is - shown. For the meaning of the -F flag, see the FORMATS section. - This command only works from inside tmux. - - join-pane [-bdhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. The -b option causes src-pane to - be joined to left of or above dst-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-a] [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. The -a option kills - all but the window given with -t. - - last-pane [-t target-window] - (alias: lastp) - Select the last (previously selected) pane. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-as] [-F format] [-t target] - (alias: lsp) - If -a is given, target is ignored and all panes on the server are - listed. If -s is given, target is a session (or the current ses- - sion). If neither is given, target is a window (or the current - window). For the meaning of the -F flag, see the FORMATS sec- - tion. - - list-windows [-a] [-F format] [-t target-session] - (alias: lsw) - If -a is given, list all windows on the server. Otherwise, list - windows in the current session or in target-session. For the - meaning of the -F flag, see the FORMATS section. - - move-pane [-bdhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: movep) - Like join-pane, but src-pane and dst-pane may belong to the same - window. - - move-window [-rdk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. With -r, all windows in the session are - renumbered in sequential order, respecting the base-index option. - - new-window [-adkP] [-c start-directory] [-n window-name] [-t - target-window] [-F format] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. -c specifies the - working directory in which the new window is created. It may - have an absolute path or one of the following values (or a subdi- - rectory): - - Empty string Current pane's directory - ~ User's home directory - - Where session was started - . Where server was started - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - The -P option prints information about the new window after it - has been created. By default, it uses the format - '#{session_name}:#{window_index}' but a different format may be - specified with -F. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with an alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left option. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with an alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRU] [-t target-pane] [adjustment] - (alias: resizep) - Resize a pane, upward with -U (the default), downward with -D, to - the left with -L and to the right with -R. The adjustment is - given in lines or cells (the default is 1). - - respawn-pane [-k] [-t target-pane] [shell-command] - (alias: respawnp) - Reactivate a pane in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the pane was created is executed. The pane - must be already inactive, unless -k is given, in which case any - existing command is killed. - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-npUu] [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. -n and - -p are equivalent to the next-layout and previous-layout com- - mands. - - -U and -u step forward and back through previous layouts, up to - the maximum set by the layout-history-limit option. - - select-pane [-lDLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. -l - is the same as using the last-pane command. - - select-window [-lnp] [-t target-window] - (alias: selectw) - Select the window at target-window. -l, -n and -p are equivalent - to the last-window, next-window and previous-window commands. - - split-window [-dhvP] [-c start-directory] [-l size | -p percentage] [-t - target-pane] [shell-command] [-F format] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage/PageDown/PgDn, PPage/PageUp/PgUp, Space, - and Tab. Note that to bind the '"' or ''' keys, quotation marks are nec- - essary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-lR] [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. The - -l flag disables key name lookup and sends the keys literally. - All arguments are sent sequentially from first to last. The -R - flag causes the terminal state to be reset. - - send-prefix [-2] [-t target-pane] - Send the prefix key, or with -2 the secondary prefix key, to a - window as if it was pressed. - - unbind-key [-acn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. If -a is - present, all key bindings are removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - Commands which set options are as follows: - - set-option [-agqsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - The -q flag suppresses the informational message (as if the quiet - server option was set). - - Available window options are listed under set-window-option. - - value depends on the option and may be a number, a string, or a - flag (on, off, or omitted to toggle). - - Available server options are: - - buffer-limit number - Set the number of buffers; as new buffers are added to - the top of the stack, old ones are removed from the bot- - tom if necessary to maintain this maximum length. - - escape-time time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - exit-unattached [on | off] - If enabled, the server will exit when there are no - attached clients. - - quiet [on | off] - Enable or disable the display of various informational - messages (see also the -q command line flag). - - set-clipboard [on | off] - Attempt to set the terminal clipboard content using the - \e]52;...\007 xterm(1) escape sequences. This option is - on by default if there is an Ms entry in the terminfo(5) - description for the client terminal. Note that this fea- - ture needs to be enabled in xterm(1) by setting the - resource: - - disallowedWindowOps: 20,21,SetXprop - - Or changing this property from the xterm(1) interactive - menu when required. - - Available session options are: - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bell in windows other than the current - window are ignored. - - bell-on-alert [on | off] - If on, ring the terminal bell when an alert occurs. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-path path - Set the default working directory for new panes. If - empty (the default), the working directory is determined - from the process running in the active pane, from the - command line environment or from the working directory - where the session was created. Otherwise the same - options are available as for the -c flag to new-window. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - destroy-unattached [on | off] - If enabled and the session is no longer attached to any - clients, it is destroyed. - - detach-on-destroy [on | off] - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, aixterm bright variants (if supported: - brightred, brightgreen, and so on), colour0 to colour255 - from the 256-colour set, default, or a hexadecimal RGB - string such as '#ffffff', which chooses the closest match - from the default 256-colour set. - - message-command-attr attributes - Set status line message attributes when in command mode. - - message-command-bg colour - Set status line message background colour when in command - mode. - - message-command-fg colour - Set status line message foreground colour when in command - mode. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-resize-pane [on | off] - If on, tmux captures the mouse and allows panes to be - resized by dragging on their borders. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - mouse-select-window [on | off] - If on, clicking the mouse on a window name in the status - line will select that window. - - mouse-utf8 [on | off] - If enabled, request mouse input as UTF-8 on UTF-8 termi- - nals. - - pane-active-border-bg colour - - pane-active-border-fg colour - Set the pane border colour for the currently active pane. - - pane-border-bg colour - - pane-border-fg colour - Set the pane border colour for panes aside from the - active pane. - - prefix key - Set the key accepted as a prefix key. - - prefix2 key - Set a secondary key accepted as a prefix key. - - renumber-windows [on | off] - If on, when a window is closed in a session, automati- - cally renumber the other windows in numerical order. - This respects the base-index option if it has been set. - If off, do not renumber the windows. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the client terminal title using the tsl - and fsl terminfo(5) entries if they exist. tmux automat- - ically sets these to the \e]2;...\007 sequence if the - terminal appears to be an xterm. This option is off by - default. Note that elinks will only attempt to set the - window title if the STY environment variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. The default is emacs, - unless the VISUAL or EDITOR environment variables are set - and contain the string 'vi'. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #h Hostname of local host without - the domain name - #F Current window flag - #I Current window index - #D Current pane unique identifier - #P Current pane index - #S Session name - #T Current pane title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - For details on how the names and titles can be set see - the NAMES AND TITLES section. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-position [top | bottom] - Set the position of the status line. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256,xterm*:XT" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION WINDOWID XAU- - THORITY". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - visual-silence [on | off] - If monitor-silence is enabled, prints a message after the - interval has expired on a given window. - - word-separators string - Sets the session's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - set-window-option [-agqu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g, -q and -u flags work similarly - to the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - allow-rename [on | off] - Allow programs to change the window name using a terminal - escape sequence (\033k...\033\\). The default is on. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities. The - alternate screen feature preserves the contents of the - window when an interactive application starts and - restores it on exit, so that any output visible before - the application starts reappears unchanged after it - exits. The default is on. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window, or with a terminal escape sequence. It - may be switched off globally with: - - set-window-option -g automatic-rename off - - c0-change-interval interval - c0-change-trigger trigger - These two options configure a simple form of rate limit- - ing for a pane. If tmux sees more than trigger C0 - sequences that modify the screen (for example, carriage - returns, linefeeds or backspaces) in one millisecond, it - will stop updating the pane immediately and instead - redraw it entirely every interval milliseconds. This - helps to prevent fast output (such as yes(1) overwhelming - the terminal). The default is a trigger of 250 and an - interval of 100. A trigger of zero disables the rate - limiting. - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - layout-history-limit limit - Set the number of previous layouts stored for recovery - with select-layout -U and -u. - - main-pane-height height - main-pane-width width - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. As with the status-keys option, the default is - emacs, unless VISUAL or EDITOR contains 'vi'. - - mode-mouse [on | off | copy-mode] - Mouse state in modes. If on, the mouse may be used to - enter copy mode and copy a selection by dragging, to - enter copy mode and scroll with the mouse wheel, or to - select an option in choice mode. If set to copy-mode, - the mouse behaves as set to on, but cannot be used to - enter copy mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - monitor-silence [interval] - Monitor for silence (no activity) in the window within - interval seconds. Windows that have been silent for the - interval are highlighted in the status line. An interval - of zero disables the monitoring. - - other-pane-height height - Set the height of the other panes (not the main pane) in - the main-horizontal layout. If this option is set to 0 - (the default), it will have no effect. If both the - main-pane-height and other-pane-height options are set, - the main pane will grow taller to make the other panes - the specified height, but will never shrink to do so. - - other-pane-width width - Like other-pane-height, but set the width of other panes - in the main-vertical layout. - - pane-base-index index - Like base-index, but set the starting index for pane num- - bers. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-bell-attr attributes - Set status line attributes for windows which have a bell - alert. - - window-status-bell-bg colour - Set status line background colour for windows with a bell - alert. - - window-status-bell-fg colour - Set status line foreground colour for windows with a bell - alert. - - window-status-content-attr attributes - Set status line attributes for windows which have a con- - tent alert. - - window-status-content-bg colour - Set status line background colour for windows with a con- - tent alert. - - window-status-content-fg colour - Set status line foreground colour for windows with a con- - tent alert. - - window-status-activity-attr attributes - Set status line attributes for windows which have an - activity (or silence) alert. - - window-status-activity-bg colour - Set status line background colour for windows with an - activity alert. - - window-status-activity-fg colour - Set status line foreground colour for windows with an - activity alert. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-separator string - Sets the separator drawn between windows in the status - line. The default is a single space character. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - wrap-search [on | off] - If this option is set, searches will wrap around the end - of the pane contents. The default is on. - - show-options [-gsw] [-t target-session | target-window] [option] - (alias: show) - Show the window options (or a single window option if given) with - -w (equivalent to show-window-options), the server options with - -s, otherwise the session options for target session. Global - session or window options are listed if -g is used. - - show-window-options [-g] [-t target-window] [option] - (alias: showw) - List the window options or a single option for target-window, or - the global window options if -g is used. - -FORMATS - Certain commands accept the -F flag with a format argument. This is a - string which controls the output format of the command. Special charac- - ter sequences are replaced as documented under the status-left option and - an additional long form is accepted. Replacement variables are enclosed - in '#{' and '}', for example '#{session_name}' is equivalent to '#S'. - Conditionals are also accepted by prefixing with '?' and separating two - alternatives with a comma; if the specified variable exists and is not - zero, the first alternative is chosen, otherwise the second is used. For - example '#{?session_attached,attached,not attached}' will include the - string 'attached' if the session is attached and the string 'not - attached' if it is unattached. - - The following variables are available, where appropriate: - - Variable name Replaced with - buffer_sample First 50 characters from the specified - buffer - buffer_size Size of the specified buffer in bytes - client_activity Integer time client last had activity - client_activity_string String time client last had activity - client_created Integer time client created - client_created_string String time client created - client_cwd Working directory of client - client_height Height of client - client_readonly 1 if client is readonly - client_termname Terminal name of client - client_tty Pseudo terminal of client - client_utf8 1 if client supports utf8 - client_width Width of client - host Hostname of local host - history_bytes Number of bytes in window history - history_limit Maximum window history lines - history_size Size of history in bytes - line Line number in the list - pane_active 1 if active pane - pane_current_path Current path if available - pane_dead 1 if pane is dead - pane_height Height of pane - pane_id Unique pane ID - pane_index Index of pane - pane_pid PID of first process in pane - pane_start_command Command pane started with - pane_start_path Path pane started with - pane_title Title of pane - pane_tty Pseudo terminal of pane - pane_width Width of pane - session_attached 1 if session attached - session_created Integer time session created - session_created_string String time session created - session_group Number of session group - session_grouped 1 if session in a group - session_height Height of session - session_name Name of session - session_width Width of session - session_windows Number of windows in session - window_active 1 if window active - window_find_matches Matched data from the find-window command - if available - window_flags Window flags - window_height Height of window - window_id Unique window ID - window_index Index of window - window_layout Window layout description - window_name Name of window - window_panes Number of panes in window - window_width Width of window - -NAMES AND TITLES - tmux distinguishes between names and titles. Windows and sessions have - names, which may be used to specify them in targets and are displayed in - the status line and various lists: the name is the tmux identifier for a - window or session. Only panes have titles. A pane's title is typically - set by the program running inside the pane and is not modified by tmux. - It is the same mechanism used to set for example the xterm(1) window - title in an X(7) window manager. Windows themselves do not have titles - - a window's title is the title of its active pane. tmux itself may set - the title of the terminal in which the client is running, see the - set-titles option. - - A session's name is set with the new-session and rename-session commands. - A window's name is set with one of: - - 1. A command argument (such as -n for new-window or new-session). - - 2. An escape sequence: - - $ printf '\033kWINDOW_NAME\033\\' - - 3. Automatic renaming, which sets the name to the active command in - the window's active pane. See the automatic-rename option. - - When a pane is first created, its title is the hostname. A pane's title - can be set via the OSC title setting sequence, for example: - - $ printf '\033]2;My Title\033\\' - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged. If a - variable exists in both, the value from the session environment is used. - The result is the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] [variable] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. If variable is omitted, all variables are shown. - Variables removed from the environment are prefixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the title of the active pane in double quotes; and the time and - date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - ~ The window has been silent for the monitor-silence - interval. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-I inputs] [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. - - If template is specified, it is used as the command. If present, - -I is a comma-separated list of the initial text for each prompt. - If -p is given, prompts is a comma-separated list of prompts - which are displayed in order; otherwise a single prompt is dis- - played, constructed from template if it is present, or ':' if - not. - - Both inputs and prompts may contain the special character - sequences supported by the status-left option. - - Before the command is executed, the first occurrence of the - string '%%' and all occurrences of '%1' are replaced by the - response to the first prompt, the second '%%' and all '%2' are - replaced with the response to the second prompt, and so on for - further prompts. Up to nine prompt responses may be replaced - ('%1' to '%9'). - - confirm-before [-p prompt] [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. If -p is given, - prompt is the prompt to display; otherwise a prompt is con- - structed from command. It may contain the special character - sequences supported by the status-left option. - - This command works only from inside tmux. - - display-message [-p] [-c target-client] [-t target-pane] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is described in the FORMATS section; infor- - mation is taken from target-pane if -t is given, otherwise the - active pane for the session attached to target-client. - -BUFFERS - tmux maintains a stack of paste buffers. Up to the value of the - buffer-limit option are kept; when a new buffer is added, the buffer at - the bottom of the stack is removed. Buffers may be added using copy-mode - or the set-buffer command, and pasted into a window using the - paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-F format] [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. For the meaning of the -F flag, see the FORMATS - section. This command works only from inside tmux. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - delete-buffer [-b buffer-index] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-F format] - (alias: lsb) - List the global buffers. For the meaning of the -F flag, see the - FORMATS section. - - load-buffer [-b buffer-index] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dpr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). If -p is speci- - fied, paste bracket control codes are inserted around the buffer - if the application has requested bracketed paste mode. - - save-buffer [-a] [-b buffer-index] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell shell-command command [command] - (alias: if) - Execute the first command if shell-command returns success or the - second command otherwise. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. After it finishes, any output to stdout is displayed in - copy mode. If the command doesn't return success, the exit sta- - tus is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - -TERMINFO EXTENSIONS - tmux understands some extensions to terminfo(5): - - Cc, Cr Set the cursor colour. The first takes a single string argument - and is used to set the colour; the second takes no arguments and - restores the default cursor colour. If set, a sequence such as - this may be used to change the cursor colour from inside tmux: - - $ printf '\033]12;red\033\\' - - Cs, Csr - Change the cursor style. If set, a sequence such as this may be - used to change the cursor to an underline: - - $ printf '\033[4 q' - - If Csr is set, it will be used to reset the cursor style instead - of Cs. - - Ms This sequence can be used by tmux to store the current buffer in - the host terminal's selection (clipboard). See the set-clipboard - option above and the xterm(1) man page. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/1.8.txt b/manual/1.8.txt deleted file mode 100644 index f8c7f065f2..0000000000 --- a/manual/1.8.txt +++ /dev/null @@ -1,2178 +0,0 @@ -TMUX(1) BSD General Commands Manual TMUX(1) - -NAME - tmux -- terminal multiplexer - -SYNOPSIS - tmux [-28lCquvV] [-c shell-command] [-f file] [-L socket-name] - [-S socket-path] [command [flags]] - -DESCRIPTION - tmux is a terminal multiplexer: it enables a number of terminals to be - created, accessed, and controlled from a single screen. tmux may be - detached from a screen and continue running in the background, then later - reattached. - - When tmux is started it creates a new session with a single window and - displays it on screen. A status line at the bottom of the screen shows - information on the current session and is used to enter interactive com- - mands. - - A session is a single collection of pseudo terminals under the management - of tmux. Each session has one or more windows linked to it. A window - occupies the entire screen and may be split into rectangular panes, each - of which is a separate pseudo terminal (the pty(4) manual page documents - the technical details of pseudo terminals). Any number of tmux instances - may connect to the same session, and any number of windows may be present - in the same session. Once all sessions are killed, tmux exits. - - Each session is persistent and will survive accidental disconnection - (such as ssh(1) connection timeout) or intentional detaching (with the - 'C-b d' key strokes). tmux may be reattached using: - - $ tmux attach - - In tmux, a session is displayed on screen by a client and all sessions - are managed by a single server. The server and each client are separate - processes which communicate through a socket in /tmp. - - The options are as follows: - - -2 Force tmux to assume the terminal supports 256 colours. - - -8 Like -2, but indicates that the terminal supports 88 - colours. - - -C Start in control mode. Given twice (-CC) disables echo. - - -c shell-command - Execute shell-command using the default shell. If neces- - sary, the tmux server will be started to retrieve the - default-shell option. This option is for compatibility - with sh(1) when tmux is used as a login shell. - - -f file Specify an alternative configuration file. By default, - tmux loads the system configuration file from - /etc/tmux.conf, if present, then looks for a user configu- - ration file at ~/.tmux.conf. - - The configuration file is a set of tmux commands which are - executed in sequence when the server is first started. - tmux loads configuration files once when the server process - has started. The source-file command may be used to load a - file later. - - tmux shows any error messages from commands in configura- - tion files in the first session created, and continues to - process the rest of the configuration file. - - -L socket-name - tmux stores the server socket in a directory under /tmp (or - TMPDIR if set); the default socket is named default. This - option allows a different socket name to be specified, - allowing several independent tmux servers to be run. - Unlike -S a full path is not necessary: the sockets are all - created in the same directory. - - If the socket is accidentally removed, the SIGUSR1 signal - may be sent to the tmux server process to recreate it. - - -l Behave as a login shell. This flag currently has no effect - and is for compatibility with other shells when using tmux - as a login shell. - - -q Set the quiet server option to prevent the server sending - various informational messages. - - -S socket-path - Specify a full alternative path to the server socket. If - -S is specified, the default socket directory is not used - and any -L flag is ignored. - - -u tmux attempts to guess if the terminal is likely to support - UTF-8 by checking the first of the LC_ALL, LC_CTYPE and - LANG environment variables to be set for the string - "UTF-8". This is not always correct: the -u flag explic- - itly informs tmux that UTF-8 is supported. - - If the server is started from a client passed -u or where - UTF-8 is detected, the utf8 and status-utf8 options are - enabled in the global window and session options respec- - tively. - - -v Request verbose logging. This option may be specified mul- - tiple times for increasing verbosity. Log messages will be - saved into tmux-client-PID.log and tmux-server-PID.log - files in the current directory, where PID is the PID of the - server or client process. - - -V Report the tmux version. - - command [flags] - This specifies one of a set of commands used to control - tmux, as described in the following sections. If no com- - mands are specified, the new-session command is assumed. - -KEY BINDINGS - tmux may be controlled from an attached client by using a key combination - of a prefix key, 'C-b' (Ctrl-b) by default, followed by a command key. - - The default command key bindings are: - - C-b Send the prefix key (C-b) through to the application. - C-o Rotate the panes in the current window forwards. - C-z Suspend the tmux client. - ! Break the current pane out of the window. - " Split the current pane into two, top and bottom. - # List all paste buffers. - $ Rename the current session. - % Split the current pane into two, left and right. - & Kill the current window. - ' Prompt for a window index to select. - , Rename the current window. - - Delete the most recently copied buffer of text. - . Prompt for an index to move the current window. - 0 to 9 Select windows 0 to 9. - : Enter the tmux command prompt. - ; Move to the previously active pane. - = Choose which buffer to paste interactively from a list. - ? List all key bindings. - D Choose a client to detach. - [ Enter copy mode to copy text or view the history. - ] Paste the most recently copied buffer of text. - c Create a new window. - d Detach the current client. - f Prompt to search for text in open windows. - i Display some information about the current window. - l Move to the previously selected window. - n Change to the next window. - o Select the next pane in the current window. - p Change to the previous window. - q Briefly display pane indexes. - r Force redraw of the attached client. - s Select a new session for the attached client interac- - tively. - L Switch the attached client back to the last session. - t Show the time. - w Choose the current window interactively. - x Kill the current pane. - { Swap the current pane with the previous pane. - } Swap the current pane with the next pane. - ~ Show previous messages from tmux, if any. - Page Up Enter copy mode and scroll one page up. - Up, Down - Left, Right - Change to the pane above, below, to the left, or to the - right of the current pane. - M-1 to M-5 Arrange panes in one of the five preset layouts: even- - horizontal, even-vertical, main-horizontal, main-verti- - cal, or tiled. - M-n Move to the next window with a bell or activity marker. - M-o Rotate the panes in the current window backwards. - M-p Move to the previous window with a bell or activity - marker. - C-Up, C-Down - C-Left, C-Right - Resize the current pane in steps of one cell. - M-Up, M-Down - M-Left, M-Right - Resize the current pane in steps of five cells. - - Key bindings may be changed with the bind-key and unbind-key commands. - -COMMANDS - This section contains a list of the commands supported by tmux. Most - commands accept the optional -t argument with one of target-client, - target-session target-window, or target-pane. These specify the client, - session, window or pane which a command should affect. target-client is - the name of the pty(4) file to which the client is connected, for example - either of /dev/ttyp1 or ttyp1 for the client attached to /dev/ttyp1. If - no client is specified, the current client is chosen, if possible, or an - error is reported. Clients may be listed with the list-clients command. - - target-session is the session id prefixed with a $, the name of a session - (as listed by the list-sessions command), or the name of a client with - the same syntax as target-client, in which case the session attached to - the client is used. When looking for the session name, tmux initially - searches for an exact match; if none is found, the session names are - checked for any for which target-session is a prefix or for which it - matches as an fnmatch(3) pattern. If a single match is found, it is used - as the target session; multiple matches produce an error. If a session - is omitted, the current session is used if available; if no current ses- - sion is available, the most recently used is chosen. - - target-window specifies a window in the form session:window. session - follows the same rules as for target-session, and window is looked for in - order: as a window index, for example mysession:1; as a window ID, such - as @1; as an exact window name, such as mysession:mywindow; then as an - fnmatch(3) pattern or the start of a window name, such as myses- - sion:mywin* or mysession:mywin. An empty window name specifies the next - unused index if appropriate (for example the new-window and link-window - commands) otherwise the current window in session is chosen. The special - character '!' uses the last (previously current) window, '^' selects the - highest numbered window, '$' selects the lowest numbered window, and '+' - and '-' select the next window or the previous window by number. When - the argument does not contain a colon, tmux first attempts to parse it as - window; if that fails, an attempt is made to match a session. - - target-pane takes a similar form to target-window but with the optional - addition of a period followed by a pane index, for example: myses- - sion:mywindow.1. If the pane index is omitted, the currently active pane - in the specified window is used. If neither a colon nor period appears, - tmux first attempts to use the argument as a pane index; if that fails, - it is looked up as for target-window. A '+' or '-' indicate the next or - previous pane index, respectively. One of the strings top, bottom, left, - right, top-left, top-right, bottom-left or bottom-right may be used - instead of a pane index. - - The special characters '+' and '-' may be followed by an offset, for - example: - - select-window -t:+2 - - When dealing with a session that doesn't contain sequential window - indexes, they will be correctly skipped. - - tmux also gives each pane created in a server an identifier consisting of - a '%' and a number, starting from zero. A pane's identifier is unique - for the life of the tmux server and is passed to the child process of the - pane in the TMUX_PANE environment variable. It may be used alone to tar- - get a pane or the window containing it. - - shell-command arguments are sh(1) commands. These must be passed as a - single item, which typically means quoting them, for example: - - new-window 'vi /etc/passwd' - - command [arguments] refers to a tmux command, passed with the command and - arguments separately, for example: - - bind-key F1 set-window-option force-width 81 - - Or if using sh(1): - - $ tmux bind-key F1 set-window-option force-width 81 - - Multiple commands may be specified together as part of a command - sequence. Each command should be separated by spaces and a semicolon; - commands are executed sequentially from left to right and lines ending - with a backslash continue on to the next line, except when escaped by - another backslash. A literal semicolon may be included by escaping it - with a backslash (for example, when specifying a command sequence to - bind-key). - - Example tmux commands include: - - refresh-client -t/dev/ttyp2 - - rename-session -tfirst newname - - set-window-option -t:0 monitor-activity on - - new-window ; split-window -d - - bind-key R source-file ~/.tmux.conf \; \ - display-message "source-file done" - - Or from sh(1): - - $ tmux kill-window -t :1 - - $ tmux new-window \; split-window -d - - $ tmux new-session -d 'vi /etc/passwd' \; split-window -d \; attach - -CLIENTS AND SESSIONS - The tmux server manages clients, sessions, windows and panes. Clients - are attached to sessions to interact with them, either when they are cre- - ated with the new-session command, or later with the attach-session com- - mand. Each session has one or more windows linked into it. Windows may - be linked to multiple sessions and are made up of one or more panes, each - of which contains a pseudo terminal. Commands for creating, linking and - otherwise manipulating windows are covered in the WINDOWS AND PANES sec- - tion. - - The following commands are available to manage clients and sessions: - - attach-session [-dr] [-t target-session] - (alias: attach) - If run from outside tmux, create a new client in the current ter- - minal and attach it to target-session. If used from inside, - switch the current client. If -d is specified, any other clients - attached to the session are detached. -r signifies the client is - read-only (only keys bound to the detach-client or switch-client - commands have any effect) - - If no server is started, attach-session will attempt to start it; - this will fail unless sessions are created in the configuration - file. - - The target-session rules for attach-session are slightly - adjusted: if tmux needs to select the most recently used session, - it will prefer the most recently used unattached session. - - detach-client [-P] [-a] [-s target-session] [-t target-client] - (alias: detach) - Detach the current client if bound to a key, the client specified - with -t, or all clients currently attached to the session speci- - fied by -s. The -a option kills all but the client given with - -t. If -P is given, send SIGHUP to the parent process of the - client, typically causing it to exit. - - has-session [-t target-session] - (alias: has) - Report an error and exit with 1 if the specified session does not - exist. If it does exist, exit with 0. - - kill-server - Kill the tmux server and clients and destroy all sessions. - - kill-session - [-a] [-t target-session] Destroy the given session, closing any - windows linked to it and no other sessions, and detaching all - clients attached to it. If -a is given, all sessions but the - specified one is killed. - - list-clients [-F format] [-t target-session] - (alias: lsc) - List all clients attached to the server. For the meaning of the - -F flag, see the FORMATS section. If target-session is speci- - fied, list only clients connected to that session. - - list-commands - (alias: lscm) - List the syntax of all commands supported by tmux. - - list-sessions [-F format] - (alias: ls) - List all sessions managed by the server. For the meaning of the - -F flag, see the FORMATS section. - - lock-client [-t target-client] - (alias: lockc) - Lock target-client, see the lock-server command. - - lock-session [-t target-session] - (alias: locks) - Lock all clients attached to target-session. - - new-session [-AdDP] [-F format] [-n window-name] [-s session-name] [-t - target-session] [-x width] [-y height] [shell-command] - (alias: new) - Create a new session with name session-name. - - The new session is attached to the current terminal unless -d is - given. window-name and shell-command are the name of and shell - command to execute in the initial window. If -d is used, -x and - -y specify the size of the initial window (80 by 24 if not - given). - - If run from a terminal, any termios(4) special characters are - saved and used for new windows in the new session. - - The -A flag makes new-session behave like attach-session if - session-name already exists; in the case, -D behaves like -d to - attach-session. - - If -t is given, the new session is grouped with target-session. - This means they share the same set of windows - all windows from - target-session are linked to the new session and any subsequent - new windows or windows being closed are applied to both sessions. - The current and previous window and any session options remain - independent and either session may be killed without affecting - the other. Giving -n or shell-command are invalid if -t is used. - - The -P option prints information about the new session after it - has been created. By default, it uses the format - '#{session_name}:' but a different format may be specified with - -F. - - refresh-client [-S] [-t target-client] - (alias: refresh) - Refresh the current client if bound to a key, or a single client - if one is given with -t. If -S is specified, only update the - client's status bar. - - rename-session [-t target-session] new-name - (alias: rename) - Rename the session to new-name. - - show-messages [-t target-client] - (alias: showmsgs) - Any messages displayed on the status line are saved in a per- - client message log, up to a maximum of the limit set by the - message-limit session option for the session attached to that - client. This command displays the log for target-client. - - source-file path - (alias: source) - Execute commands from path. - - start-server - (alias: start) - Start the tmux server, if not already running, without creating - any sessions. - - suspend-client [-t target-client] - (alias: suspendc) - Suspend a client by sending SIGTSTP (tty stop). - - switch-client [-lnpr] [-c target-client] [-t target-session] - (alias: switchc) - Switch the current session for client target-client to - target-session. If -l, -n or -p is used, the client is moved to - the last, next or previous session respectively. -r toggles - whether a client is read-only (see the attach-session command). - -WINDOWS AND PANES - A tmux window may be in one of several modes. The default permits direct - access to the terminal attached to the window. The other is copy mode, - which permits a section of a window or its history to be copied to a - paste buffer for later insertion into another window. This mode is - entered with the copy-mode command, bound to '[' by default. It is also - entered when a command that produces output, such as list-keys, is exe- - cuted from a key binding. - - The keys available depend on whether emacs or vi mode is selected (see - the mode-keys option). The following keys are supported as appropriate - for the mode: - - Function vi emacs - Back to indentation ^ M-m - Bottom of history G M-< - Clear selection Escape C-g - Copy selection Enter M-w - Cursor down j Down - Cursor left h Left - Cursor right l Right - Cursor to bottom line L - Cursor to middle line M M-r - Cursor to top line H M-R - Cursor up k Up - Delete entire line d C-u - Delete/Copy to end of line D C-k - End of line $ C-e - Go to line : g - Half page down C-d M-Down - Half page up C-u M-Up - Jump forward f f - Jump to forward t - Jump backward F F - Jump to backward T - Jump again ; ; - Jump again in reverse , , - Next page C-f Page down - Next space W - Next space, end of word E - Next word w - Next word end e M-f - Paste buffer p C-y - Previous page C-b Page up - Previous word b M-b - Previous space B - Quit mode q Escape - Rectangle toggle v R - Scroll down C-Down or C-e C-Down - Scroll up C-Up or C-y C-Up - Search again n n - Search again in reverse N N - Search backward ? C-r - Search forward / C-s - Start of line 0 C-a - Start selection Space C-Space - Top of history g M-> - Transpose characters C-t - - The next and previous word keys use space and the '-', '_' and '@' char- - acters as word delimiters by default, but this can be adjusted by setting - the word-separators session option. Next word moves to the start of the - next word, next word end to the end of the next word and previous word to - the start of the previous word. The three next and previous space keys - work similarly but use a space alone as the word separator. - - The jump commands enable quick movement within a line. For instance, - typing 'f' followed by '/' will move the cursor to the next '/' character - on the current line. A ';' will then jump to the next occurrence. - - Commands in copy mode may be prefaced by an optional repeat count. With - vi key bindings, a prefix is entered using the number keys; with emacs, - the Alt (meta) key and a number begins prefix entry. For example, to - move the cursor forward by ten words, use 'M-1 0 M-f' in emacs mode, and - '10w' in vi. - - When copying the selection, the repeat count indicates the buffer index - to replace, if used. - - Mode key bindings are defined in a set of named tables: vi-edit and - emacs-edit for keys used when line editing at the command prompt; - vi-choice and emacs-choice for keys used when choosing from lists (such - as produced by the choose-window command); and vi-copy and emacs-copy - used in copy mode. The tables may be viewed with the list-keys command - and keys modified or removed with bind-key and unbind-key. One command - accepts an argument, copy-pipe, which copies the selection and pipes it - to a command. For example the following will bind 'C-q' to copy the - selection into /tmp as well as the paste buffer: - - bind-key -temacs-copy C-q copy-pipe "cat >/tmp/out" - - The paste buffer key pastes the first line from the top paste buffer on - the stack. - - The synopsis for the copy-mode command is: - - copy-mode [-u] [-t target-pane] - Enter copy mode. The -u option scrolls one page up. - - Each window displayed by tmux may be split into one or more panes; each - pane takes up a certain area of the display and is a separate terminal. - A window may be split into panes using the split-window command. Windows - may be split horizontally (with the -h flag) or vertically. Panes may be - resized with the resize-pane command (bound to 'C-up', 'C-down' 'C-left' - and 'C-right' by default), the current pane may be changed with the - select-pane command and the rotate-window and swap-pane commands may be - used to swap panes without changing their position. Panes are numbered - beginning from zero in the order they are created. - - A number of preset layouts are available. These may be selected with the - select-layout command or cycled with next-layout (bound to 'Space' by - default); once a layout is chosen, panes within it may be moved and - resized as normal. - - The following layouts are supported: - - even-horizontal - Panes are spread out evenly from left to right across the window. - - even-vertical - Panes are spread evenly from top to bottom. - - main-horizontal - A large (main) pane is shown at the top of the window and the - remaining panes are spread from left to right in the leftover - space at the bottom. Use the main-pane-height window option to - specify the height of the top pane. - - main-vertical - Similar to main-horizontal but the large pane is placed on the - left and the others spread from top to bottom along the right. - See the main-pane-width window option. - - tiled Panes are spread out as evenly as possible over the window in - both rows and columns. - - In addition, select-layout may be used to apply a previously used layout - - the list-windows command displays the layout of each window in a form - suitable for use with select-layout. For example: - - $ tmux list-windows - 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} - $ tmux select-layout bb62,159x48,0,0{79x48,0,0,79x48,80,0} - - tmux automatically adjusts the size of the layout for the current window - size. Note that a layout cannot be applied to a window with more panes - than that from which the layout was originally defined. - - Commands related to windows and panes are as follows: - - break-pane [-dP] [-F format] [-t target-pane] - (alias: breakp) - Break target-pane off from its containing window to make it the - only pane in a new window. If -d is given, the new window does - not become the current window. The -P option prints information - about the new window after it has been created. By default, it - uses the format '#{session_name}:#{window_index}' but a different - format may be specified with -F. - - capture-pane [-aepPq] [-b buffer-index] [-E end-line] [-S start-line] [-t - target-pane] - (alias: capturep) - Capture the contents of a pane. If -p is given, the output goes - to stdout, otherwise to the buffer specified with -b or a new - buffer if omitted. If -a is given, the alternate screen is used, - and the history is not accessible. If no alternate screen - exists, an error will be returned unless -q is given. If -e is - given, the output includes escape sequences for text and back- - ground attributes. -C also escapes non-printable characters as - octal \xxx. -J joins wrapped lines and preserves trailing spaces - at each line's end. -P captures only any output that the pane - has received that is the beginning of an as-yet incomplete escape - sequence. - - -S and -E specify the starting and ending line numbers, zero is - the first line of the visible pane and negative numbers are lines - in the history. The default is to capture only the visible con- - tents of the pane. - - choose-client [-F format] [-t target-window] [template] - Put a window into client choice mode, allowing a client to be - selected interactively from a list. After a client is chosen, - '%%' is replaced by the client pty(4) path in template and the - result executed as a command. If template is not given, "detach- - client -t '%%'" is used. For the meaning of the -F flag, see the - FORMATS section. This command works only if at least one client - is attached. - - choose-list [-l items] [-t target-window] [template] - Put a window into list choice mode, allowing items to be - selected. items can be a comma-separated list to display more - than one item. If an item has spaces, that entry must be quoted. - After an item is chosen, '%%' is replaced by the chosen item in - the template and the result is executed as a command. If - template is not given, "run-shell '%%'" is used. items also - accepts format specifiers. For the meaning of this see the - FORMATS section. This command works only if at least one client - is attached. - - choose-session [-F format] [-t target-window] [template] - Put a window into session choice mode, where a session may be - selected interactively from a list. When one is chosen, '%%' is - replaced by the session name in template and the result executed - as a command. If template is not given, "switch-client -t '%%'" - is used. For the meaning of the -F flag, see the FORMATS sec- - tion. This command works only if at least one client is - attached. - - choose-tree [-suw] [-b session-template] [-c window-template] [-S format] - [-W format] [-t target-window] - Put a window into tree choice mode, where either sessions or win- - dows may be selected interactively from a list. By default, win- - dows belonging to a session are indented to show their relation- - ship to a session. - - Note that the choose-window and choose-session commands are wrap- - pers around choose-tree. - - If -s is given, will show sessions. If -w is given, will show - windows. - - By default, the tree is collapsed and sessions must be expanded - to windows with the right arrow key. The -u option will start - with all sessions expanded instead. - - If -b is given, will override the default session command. Note - that '%%' can be used and will be replaced with the session name. - The default option if not specified is "switch-client -t '%%'". - If -c is given, will override the default window command. Like - -b, '%%' can be used and will be replaced with the session name - and window index. When a window is chosen from the list, the - session command is run before the window command. - - If -S is given will display the specified format instead of the - default session format. If -W is given will display the speci- - fied format instead of the default window format. For the mean- - ing of the -s and -w options, see the FORMATS section. - - This command works only if at least one client is attached. - - choose-window [-F format] [-t target-window] [template] - Put a window into window choice mode, where a window may be cho- - sen interactively from a list. After a window is selected, '%%' - is replaced by the session name and window index in template and - the result executed as a command. If template is not given, - "select-window -t '%%'" is used. For the meaning of the -F flag, - see the FORMATS section. This command works only if at least one - client is attached. - - display-panes [-t target-client] - (alias: displayp) - Display a visible indicator of each pane shown by target-client. - See the display-panes-time, display-panes-colour, and - display-panes-active-colour session options. While the indicator - is on screen, a pane may be selected with the '0' to '9' keys. - - find-window [-CNT] [-F format] [-t target-window] match-string - (alias: findw) - Search for the fnmatch(3) pattern match-string in window names, - titles, and visible content (but not history). The flags control - matching behavior: -C matches only visible window contents, -N - matches only the window name and -T matches only the window - title. The default is -CNT. If only one window is matched, - it'll be automatically selected, otherwise a choice list is - shown. For the meaning of the -F flag, see the FORMATS section. - This command works only if at least one client is attached. - - join-pane [-bdhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: joinp) - Like split-window, but instead of splitting dst-pane and creating - a new pane, split it and move src-pane into the space. This can - be used to reverse break-pane. The -b option causes src-pane to - be joined to left of or above dst-pane. - - kill-pane [-a] [-t target-pane] - (alias: killp) - Destroy the given pane. If no panes remain in the containing - window, it is also destroyed. The -a option kills all but the - pane given with -t. - - kill-window [-a] [-t target-window] - (alias: killw) - Kill the current window or the window at target-window, removing - it from any sessions to which it is linked. The -a option kills - all but the window given with -t. - - last-pane [-t target-window] - (alias: lastp) - Select the last (previously selected) pane. - - last-window [-t target-session] - (alias: last) - Select the last (previously selected) window. If no - target-session is specified, select the last window of the cur- - rent session. - - link-window [-dk] [-s src-window] [-t dst-window] - (alias: linkw) - Link the window at src-window to the specified dst-window. If - dst-window is specified and no such window exists, the src-window - is linked there. If -k is given and dst-window exists, it is - killed, otherwise an error is generated. If -d is given, the - newly linked window is not selected. - - list-panes [-as] [-F format] [-t target] - (alias: lsp) - If -a is given, target is ignored and all panes on the server are - listed. If -s is given, target is a session (or the current ses- - sion). If neither is given, target is a window (or the current - window). For the meaning of the -F flag, see the FORMATS sec- - tion. - - list-windows [-a] [-F format] [-t target-session] - (alias: lsw) - If -a is given, list all windows on the server. Otherwise, list - windows in the current session or in target-session. For the - meaning of the -F flag, see the FORMATS section. - - move-pane [-bdhv] [-l size | -p percentage] [-s src-pane] [-t dst-pane] - (alias: movep) - Like join-pane, but src-pane and dst-pane may belong to the same - window. - - move-window [-rdk] [-s src-window] [-t dst-window] - (alias: movew) - This is similar to link-window, except the window at src-window - is moved to dst-window. With -r, all windows in the session are - renumbered in sequential order, respecting the base-index option. - - new-window [-adkP] [-c start-directory] [-F format] [-n window-name] [-t - target-window] [shell-command] - (alias: neww) - Create a new window. With -a, the new window is inserted at the - next index up from the specified target-window, moving windows up - if necessary, otherwise target-window is the new window location. - - If -d is given, the session does not make the new window the cur- - rent window. target-window represents the window to be created; - if the target already exists an error is shown, unless the -k - flag is used, in which case it is destroyed. shell-command is - the command to execute. If shell-command is not specified, the - value of the default-command option is used. -c specifies the - working directory in which the new window is created. It may - have an absolute path or one of the following values (or a subdi- - rectory): - - Empty string Current pane's directory - ~ User's home directory - - Where session was started - . Where server was started - - When the shell command completes, the window closes. See the - remain-on-exit option to change this behaviour. - - The TERM environment variable must be set to ``screen'' for all - programs running inside tmux. New windows will automatically - have ``TERM=screen'' added to their environment, but care must be - taken not to reset this in shell start-up files. - - The -P option prints information about the new window after it - has been created. By default, it uses the format - '#{session_name}:#{window_index}' but a different format may be - specified with -F. - - next-layout [-t target-window] - (alias: nextl) - Move a window to the next layout and rearrange the panes to fit. - - next-window [-a] [-t target-session] - (alias: next) - Move to the next window in the session. If -a is used, move to - the next window with an alert. - - pipe-pane [-o] [-t target-pane] [shell-command] - (alias: pipep) - Pipe any output sent by the program in target-pane to a shell - command. A pane may only be piped to one command at a time, any - existing pipe is closed before shell-command is executed. The - shell-command string may contain the special character sequences - supported by the status-left option. If no shell-command is - given, the current pipe (if any) is closed. - - The -o option only opens a new pipe if no previous pipe exists, - allowing a pipe to be toggled with a single key, for example: - - bind-key C-p pipe-pane -o 'cat >>~/output.#I-#P' - - previous-layout [-t target-window] - (alias: prevl) - Move to the previous layout in the session. - - previous-window [-a] [-t target-session] - (alias: prev) - Move to the previous window in the session. With -a, move to the - previous window with an alert. - - rename-window [-t target-window] new-name - (alias: renamew) - Rename the current window, or the window at target-window if - specified, to new-name. - - resize-pane [-DLRUZ] [-t target-pane] [-x width] [-y height] [adjustment] - (alias: resizep) - Resize a pane, up, down, left or right by adjustment with -U, -D, - -L or -R, or to an absolute size with -x or -y. The adjustment - is given in lines or cells (the default is 1). - - With -Z, the active pane is toggled between zoomed (occupying the - whole of the window) and unzoomed (its normal position in the - layout). - - respawn-pane [-k] [-t target-pane] [shell-command] - (alias: respawnp) - Reactivate a pane in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the pane was created is executed. The pane - must be already inactive, unless -k is given, in which case any - existing command is killed. - - respawn-window [-k] [-t target-window] [shell-command] - (alias: respawnw) - Reactivate a window in which the command has exited (see the - remain-on-exit window option). If shell-command is not given, - the command used when the window was created is executed. The - window must be already inactive, unless -k is given, in which - case any existing command is killed. - - rotate-window [-DU] [-t target-window] - (alias: rotatew) - Rotate the positions of the panes within a window, either upward - (numerically lower) with -U or downward (numerically higher). - - select-layout [-np] [-t target-window] [layout-name] - (alias: selectl) - Choose a specific layout for a window. If layout-name is not - given, the last preset layout used (if any) is reapplied. -n and - -p are equivalent to the next-layout and previous-layout com- - mands. - - select-pane [-lDLRU] [-t target-pane] - (alias: selectp) - Make pane target-pane the active pane in window target-window. - If one of -D, -L, -R, or -U is used, respectively the pane below, - to the left, to the right, or above the target pane is used. -l - is the same as using the last-pane command. - - select-window [-lnpT] [-t target-window] - (alias: selectw) - Select the window at target-window. -l, -n and -p are equivalent - to the last-window, next-window and previous-window commands. If - -T is given and the selected window is already the current win- - dow, the command behaves like last-window. - - split-window [-dhvP] [-c start-directory] [-l size | -p percentage] [-t - target-pane] [shell-command] [-F format] - (alias: splitw) - Create a new pane by splitting target-pane: -h does a horizontal - split and -v a vertical split; if neither is specified, -v is - assumed. The -l and -p options specify the size of the new pane - in lines (for vertical split) or in cells (for horizontal split), - or as a percentage, respectively. All other options have the - same meaning as for the new-window command. - - swap-pane [-dDU] [-s src-pane] [-t dst-pane] - (alias: swapp) - Swap two panes. If -U is used and no source pane is specified - with -s, dst-pane is swapped with the previous pane (before it - numerically); -D swaps with the next pane (after it numerically). - -d instructs tmux not to change the active pane. - - swap-window [-d] [-s src-window] [-t dst-window] - (alias: swapw) - This is similar to link-window, except the source and destination - windows are swapped. It is an error if no window exists at - src-window. - - unlink-window [-k] [-t target-window] - (alias: unlinkw) - Unlink target-window. Unless -k is given, a window may be - unlinked only if it is linked to multiple sessions - windows may - not be linked to no sessions; if -k is specified and the window - is linked to only one session, it is unlinked and destroyed. - -KEY BINDINGS - tmux allows a command to be bound to most keys, with or without a prefix - key. When specifying keys, most represent themselves (for example 'A' to - 'Z'). Ctrl keys may be prefixed with 'C-' or '^', and Alt (meta) with - 'M-'. In addition, the following special key names are accepted: Up, - Down, Left, Right, BSpace, BTab, DC (Delete), End, Enter, Escape, F1 to - F20, Home, IC (Insert), NPage/PageDown/PgDn, PPage/PageUp/PgUp, Space, - and Tab. Note that to bind the '"' or ''' keys, quotation marks are nec- - essary, for example: - - bind-key '"' split-window - bind-key "'" new-window - - Commands related to key bindings are as follows: - - bind-key [-cnr] [-t key-table] key command [arguments] - (alias: bind) - Bind key key to command. By default (without -t) the primary key - bindings are modified (those normally activated with the prefix - key); in this case, if -n is specified, it is not necessary to - use the prefix key, command is bound to key alone. The -r flag - indicates this key may repeat, see the repeat-time option. - - If -t is present, key is bound in key-table: the binding for com- - mand mode with -c or for normal mode without. To view the - default bindings and possible commands, see the list-keys com- - mand. - - list-keys [-t key-table] - (alias: lsk) - List all key bindings. Without -t the primary key bindings - - those executed when preceded by the prefix key - are printed. - - With -t, the key bindings in key-table are listed; this may be - one of: vi-edit, emacs-edit, vi-choice, emacs-choice, vi-copy or - emacs-copy. - - send-keys [-lR] [-t target-pane] key ... - (alias: send) - Send a key or keys to a window. Each argument key is the name of - the key (such as 'C-a' or 'npage' ) to send; if the string is not - recognised as a key, it is sent as a series of characters. The - -l flag disables key name lookup and sends the keys literally. - All arguments are sent sequentially from first to last. The -R - flag causes the terminal state to be reset. - - send-prefix [-2] [-t target-pane] - Send the prefix key, or with -2 the secondary prefix key, to a - window as if it was pressed. - - unbind-key [-acn] [-t key-table] key - (alias: unbind) - Unbind the command bound to key. Without -t the primary key - bindings are modified; in this case, if -n is specified, the com- - mand bound to key without a prefix (if any) is removed. If -a is - present, all key bindings are removed. - - If -t is present, key in key-table is unbound: the binding for - command mode with -c or for normal mode without. - -OPTIONS - The appearance and behaviour of tmux may be modified by changing the - value of various options. There are three types of option: server - options, session options and window options. - - The tmux server has a set of global options which do not apply to any - particular window or session. These are altered with the set-option -s - command, or displayed with the show-options -s command. - - In addition, each individual session may have a set of session options, - and there is a separate set of global session options. Sessions which do - not have a particular option configured inherit the value from the global - session options. Session options are set or unset with the set-option - command and may be listed with the show-options command. The available - server and session options are listed under the set-option command. - - Similarly, a set of window options is attached to each window, and there - is a set of global window options from which any unset options are inher- - ited. Window options are altered with the set-window-option command and - can be listed with the show-window-options command. All window options - are documented with the set-window-option command. - - tmux also supports user options which are prefixed with a '@'. User - options may have any name, so long as they are prefixed with '@', and be - set to any string. For example - - $ tmux setw -q @foo "abc123" - $ tmux showw -v @foo - abc123 - - Commands which set options are as follows: - - set-option [-agoqsuw] [-t target-session | target-window] option value - (alias: set) - Set a window option with -w (equivalent to the set-window-option - command), a server option with -s, otherwise a session option. - - If -g is specified, the global session or window option is set. - With -a, and if the option expects a string, value is appended to - the existing setting. The -u flag unsets an option, so a session - inherits the option from the global options. It is not possible - to unset a global option. - - The -o flag prevents setting an option that is already set. - - The -q flag suppresses the informational message (as if the quiet - server option was set). - - Available window options are listed under set-window-option. - - value depends on the option and may be a number, a string, or a - flag (on, off, or omitted to toggle). - - Available server options are: - - buffer-limit number - Set the number of buffers; as new buffers are added to - the top of the stack, old ones are removed from the bot- - tom if necessary to maintain this maximum length. - - escape-time time - Set the time in milliseconds for which tmux waits after - an escape is input to determine if it is part of a func- - tion or meta key sequences. The default is 500 millisec- - onds. - - exit-unattached [on | off] - If enabled, the server will exit when there are no - attached clients. - - quiet [on | off] - Enable or disable the display of various informational - messages (see also the -q command line flag). - - set-clipboard [on | off] - Attempt to set the terminal clipboard content using the - \e]52;...\007 xterm(1) escape sequences. This option is - on by default if there is an Ms entry in the terminfo(5) - description for the client terminal. Note that this fea- - ture needs to be enabled in xterm(1) by setting the - resource: - - disallowedWindowOps: 20,21,SetXprop - - Or changing this property from the xterm(1) interactive - menu when required. - - Available session options are: - - assume-paste-time milliseconds - If keys are entered faster than one in milliseconds, they - are assumed to have been pasted rather than typed and - tmux key bindings are not processed. The default is one - millisecond and zero disables. - - base-index index - Set the base index from which an unused index should be - searched when a new window is created. The default is - zero. - - bell-action [any | none | current] - Set action on window bell. any means a bell in any win- - dow linked to a session causes a bell in the current win- - dow of that session, none means all bells are ignored and - current means only bells in windows other than the cur- - rent window are ignored. - - bell-on-alert [on | off] - If on, ring the terminal bell when an alert occurs. - - default-command shell-command - Set the command used for new windows (if not specified - when the window is created) to shell-command, which may - be any sh(1) command. The default is an empty string, - which instructs tmux to create a login shell using the - value of the default-shell option. - - default-path path - Set the default working directory for new panes. If - empty (the default), the working directory is determined - from the process running in the active pane, from the - command line environment or from the working directory - where the session was created. Otherwise the same - options are available as for the -c flag to new-window. - - default-shell path - Specify the default shell. This is used as the login - shell for new windows when the default-command option is - set to empty, and must be the full path of the exe- - cutable. When started tmux tries to set a default value - from the first suitable of the SHELL environment vari- - able, the shell returned by getpwuid(3), or /bin/sh. - This option should be configured when tmux is used as a - login shell. - - default-terminal terminal - Set the default terminal for new windows created in this - session - the default value of the TERM environment vari- - able. For tmux to work correctly, this must be set to - 'screen' or a derivative of it. - - destroy-unattached [on | off] - If enabled and the session is no longer attached to any - clients, it is destroyed. - - detach-on-destroy [on | off] - If on (the default), the client is detached when the ses- - sion it is attached to is destroyed. If off, the client - is switched to the most recently active of the remaining - sessions. - - display-panes-active-colour colour - Set the colour used by the display-panes command to show - the indicator for the active pane. - - display-panes-colour colour - Set the colour used by the display-panes command to show - the indicators for inactive panes. - - display-panes-time time - Set the time in milliseconds for which the indicators - shown by the display-panes command appear. - - display-time time - Set the amount of time for which status line messages and - other on-screen indicators are displayed. time is in - milliseconds. - - history-limit lines - Set the maximum number of lines held in window history. - This setting applies only to new windows - existing win- - dow histories are not resized and retain the limit at the - point they were created. - - lock-after-time number - Lock the session (like the lock-session command) after - number seconds of inactivity, or the entire server (all - sessions) if the lock-server option is set. The default - is not to lock (set to 0). - - lock-command shell-command - Command to run when locking each client. The default is - to run lock(1) with -np. - - lock-server [on | off] - If this option is on (the default), instead of each ses- - sion locking individually as each has been idle for - lock-after-time, the entire server will lock after all - sessions would have locked. This has no effect as a ses- - sion option; it must be set as a global option. - - message-attr attributes - Set status line message attributes, where attributes is - either none or a comma-delimited list of one or more of: - bright (or bold), dim, underscore, blink, reverse, - hidden, or italics. - - message-bg colour - Set status line message background colour, where colour - is one of: black, red, green, yellow, blue, magenta, - cyan, white, aixterm bright variants (if supported: - brightred, brightgreen, and so on), colour0 to colour255 - from the 256-colour set, default, or a hexadecimal RGB - string such as '#ffffff', which chooses the closest match - from the default 256-colour set. - - message-command-attr attributes - Set status line message attributes when in command mode. - - message-command-bg colour - Set status line message background colour when in command - mode. - - message-command-fg colour - Set status line message foreground colour when in command - mode. - - message-fg colour - Set status line message foreground colour. - - message-limit number - Set the number of error or information messages to save - in the message log for each client. The default is 20. - - mouse-resize-pane [on | off] - If on, tmux captures the mouse and allows panes to be - resized by dragging on their borders. - - mouse-select-pane [on | off] - If on, tmux captures the mouse and when a window is split - into multiple panes the mouse may be used to select the - current pane. The mouse click is also passed through to - the application as normal. - - mouse-select-window [on | off] - If on, clicking the mouse on a window name in the status - line will select that window. - - mouse-utf8 [on | off] - If enabled, request mouse input as UTF-8 on UTF-8 termi- - nals. - - pane-active-border-bg colour - - pane-active-border-fg colour - Set the pane border colour for the currently active pane. - - pane-border-bg colour - - pane-border-fg colour - Set the pane border colour for panes aside from the - active pane. - - prefix key - Set the key accepted as a prefix key. - - prefix2 key - Set a secondary key accepted as a prefix key. - - renumber-windows [on | off] - If on, when a window is closed in a session, automati- - cally renumber the other windows in numerical order. - This respects the base-index option if it has been set. - If off, do not renumber the windows. - - repeat-time time - Allow multiple commands to be entered without pressing - the prefix-key again in the specified time milliseconds - (the default is 500). Whether a key repeats may be set - when it is bound using the -r flag to bind-key. Repeat - is enabled for the default keys bound to the resize-pane - command. - - set-remain-on-exit [on | off] - Set the remain-on-exit window option for any windows - first created in this session. When this option is true, - windows in which the running program has exited do not - close, instead remaining open but inactivate. Use the - respawn-window command to reactivate such a window, or - the kill-window command to destroy it. - - set-titles [on | off] - Attempt to set the client terminal title using the tsl - and fsl terminfo(5) entries if they exist. tmux automat- - ically sets these to the \e]2;...\007 sequence if the - terminal appears to be an xterm. This option is off by - default. Note that elinks will only attempt to set the - window title if the STY environment variable is set. - - set-titles-string string - String used to set the window title if set-titles is on. - Character sequences are replaced as for the status-left - option. - - status [on | off] - Show or hide the status line. - - status-attr attributes - Set status line attributes. - - status-bg colour - Set status line background colour. - - status-fg colour - Set status line foreground colour. - - status-interval interval - Update the status bar every interval seconds. By - default, updates will occur every 15 seconds. A setting - of zero disables redrawing at interval. - - status-justify [left | centre | right] - Set the position of the window list component of the sta- - tus line: left, centre or right justified. - - status-keys [vi | emacs] - Use vi or emacs-style key bindings in the status line, - for example at the command prompt. The default is emacs, - unless the VISUAL or EDITOR environment variables are set - and contain the string 'vi'. - - status-left string - Display string to the left of the status bar. string - will be passed through strftime(3) before being used. By - default, the session name is shown. string may contain - any of the following special character sequences: - - Character pair Replaced with - #(shell-command) First line of the command's - output - #[attributes] Colour or attribute change - #H Hostname of local host - #h Hostname of local host without - the domain name - #F Current window flag - #I Current window index - #D Current pane unique identifier - #P Current pane index - #S Session name - #T Current pane title - #W Current window name - ## A literal '#' - - The #(shell-command) form executes 'shell-command' and - inserts the first line of its output. Note that shell - commands are only executed once at the interval specified - by the status-interval option: if the status line is - redrawn in the meantime, the previous result is used. - Shell commands are executed with the tmux global environ- - ment set (see the ENVIRONMENT section). - - For details on how the names and titles can be set see - the NAMES AND TITLES section. - - #[attributes] allows a comma-separated list of attributes - to be specified, these may be 'fg=colour' to set the - foreground colour, 'bg=colour' to set the background - colour, the name of one of the attributes (listed under - the message-attr option) to turn an attribute on, or an - attribute prefixed with 'no' to turn one off, for example - nobright. Examples are: - - #(sysctl vm.loadavg) - #[fg=yellow,bold]#(apm -l)%%#[default] [#S] - - Where appropriate, special character sequences may be - prefixed with a number to specify the maximum length, for - example '#24T'. - - By default, UTF-8 in string is not interpreted, to enable - UTF-8, use the status-utf8 option. - - status-left-attr attributes - Set the attribute of the left part of the status line. - - status-left-bg colour - Set the background colour of the left part of the status - line. - - status-left-fg colour - Set the foreground colour of the left part of the status - line. - - status-left-length length - Set the maximum length of the left component of the sta- - tus bar. The default is 10. - - status-position [top | bottom] - Set the position of the status line. - - status-right string - Display string to the right of the status bar. By - default, the current window title in double quotes, the - date and the time are shown. As with status-left, string - will be passed to strftime(3), character pairs are - replaced, and UTF-8 is dependent on the status-utf8 - option. - - status-right-attr attributes - Set the attribute of the right part of the status line. - - status-right-bg colour - Set the background colour of the right part of the status - line. - - status-right-fg colour - Set the foreground colour of the right part of the status - line. - - status-right-length length - Set the maximum length of the right component of the sta- - tus bar. The default is 40. - - status-utf8 [on | off] - Instruct tmux to treat top-bit-set characters in the - status-left and status-right strings as UTF-8; notably, - this is important for wide characters. This option - defaults to off. - - terminal-overrides string - Contains a list of entries which override terminal - descriptions read using terminfo(5). string is a comma- - separated list of items each a colon-separated string - made up of a terminal type pattern (matched using - fnmatch(3)) and a set of name=value entries. - - For example, to set the 'clear' terminfo(5) entry to - '\e[H\e[2J' for all terminal types and the 'dch1' entry - to '\e[P' for the 'rxvt' terminal type, the option could - be set to the string: - - "*:clear=\e[H\e[2J,rxvt:dch1=\e[P" - - The terminal entry value is passed through strunvis(3) - before interpretation. The default value forcibly cor- - rects the 'colors' entry for terminals which support 88 - or 256 colours: - - "*88col*:colors=88,*256col*:colors=256,xterm*:XT" - - update-environment variables - Set a space-separated string containing a list of envi- - ronment variables to be copied into the session environ- - ment when a new session is created or an existing session - is attached. Any variables that do not exist in the - source environment are set to be removed from the session - environment (as if -r was given to the set-environment - command). The default is "DISPLAY SSH_ASKPASS - SSH_AUTH_SOCK SSH_AGENT_PID SSH_CONNECTION WINDOWID XAU- - THORITY". - - visual-activity [on | off] - If on, display a status line message when activity occurs - in a window for which the monitor-activity window option - is enabled. - - visual-bell [on | off] - If this option is on, a message is shown on a bell - instead of it being passed through to the terminal (which - normally makes a sound). Also see the bell-action - option. - - visual-content [on | off] - Like visual-activity, display a message when content is - present in a window for which the monitor-content window - option is enabled. - - visual-silence [on | off] - If monitor-silence is enabled, prints a message after the - interval has expired on a given window. - - word-separators string - Sets the session's conception of what characters are con- - sidered word separators, for the purposes of the next and - previous word commands in copy mode. The default is - ' -_@'. - - set-window-option [-agqu] [-t target-window] option value - (alias: setw) - Set a window option. The -a, -g, -q and -u flags work similarly - to the set-option command. - - Supported window options are: - - aggressive-resize [on | off] - Aggressively resize the chosen window. This means that - tmux will resize the window to the size of the smallest - session for which it is the current window, rather than - the smallest session to which it is attached. The window - may resize when the current window is changed on another - sessions; this option is good for full-screen programs - which support SIGWINCH and poor for interactive programs - such as shells. - - allow-rename [on | off] - Allow programs to change the window name using a terminal - escape sequence (\033k...\033\\). The default is on. - - alternate-screen [on | off] - This option configures whether programs running inside - tmux may use the terminal alternate screen feature, which - allows the smcup and rmcup terminfo(5) capabilities. The - alternate screen feature preserves the contents of the - window when an interactive application starts and - restores it on exit, so that any output visible before - the application starts reappears unchanged after it - exits. The default is on. - - automatic-rename [on | off] - Control automatic window renaming. When this setting is - enabled, tmux will attempt - on supported platforms - to - rename the window to reflect the command currently run- - ning in it. This flag is automatically disabled for an - individual window when a name is specified at creation - with new-window or new-session, or later with - rename-window, or with a terminal escape sequence. It - may be switched off globally with: - - set-window-option -g automatic-rename off - - c0-change-interval interval - c0-change-trigger trigger - These two options configure a simple form of rate limit- - ing for a pane. If tmux sees more than trigger C0 - sequences that modify the screen (for example, carriage - returns, linefeeds or backspaces) in one millisecond, it - will stop updating the pane immediately and instead - redraw it entirely every interval milliseconds. This - helps to prevent fast output (such as yes(1) overwhelming - the terminal). The default is a trigger of 250 and an - interval of 100. A trigger of zero disables the rate - limiting. - - clock-mode-colour colour - Set clock colour. - - clock-mode-style [12 | 24] - Set clock hour format. - - force-height height - force-width width - Prevent tmux from resizing a window to greater than width - or height. A value of zero restores the default unlim- - ited setting. - - main-pane-height height - main-pane-width width - Set the width or height of the main (left or top) pane in - the main-horizontal or main-vertical layouts. - - mode-attr attributes - Set window modes attributes. - - mode-bg colour - Set window modes background colour. - - mode-fg colour - Set window modes foreground colour. - - mode-keys [vi | emacs] - Use vi or emacs-style key bindings in copy and choice - modes. As with the status-keys option, the default is - emacs, unless VISUAL or EDITOR contains 'vi'. - - mode-mouse [on | off | copy-mode] - Mouse state in modes. If on, the mouse may be used to - enter copy mode and copy a selection by dragging, to - enter copy mode and scroll with the mouse wheel, or to - select an option in choice mode. If set to copy-mode, - the mouse behaves as set to on, but cannot be used to - enter copy mode. - - monitor-activity [on | off] - Monitor for activity in the window. Windows with activ- - ity are highlighted in the status line. - - monitor-content match-string - Monitor content in the window. When fnmatch(3) pattern - match-string appears in the window, it is highlighted in - the status line. - - monitor-silence [interval] - Monitor for silence (no activity) in the window within - interval seconds. Windows that have been silent for the - interval are highlighted in the status line. An interval - of zero disables the monitoring. - - other-pane-height height - Set the height of the other panes (not the main pane) in - the main-horizontal layout. If this option is set to 0 - (the default), it will have no effect. If both the - main-pane-height and other-pane-height options are set, - the main pane will grow taller to make the other panes - the specified height, but will never shrink to do so. - - other-pane-width width - Like other-pane-height, but set the width of other panes - in the main-vertical layout. - - pane-base-index index - Like base-index, but set the starting index for pane num- - bers. - - remain-on-exit [on | off] - A window with this flag set is not destroyed when the - program running in it exits. The window may be reacti- - vated with the respawn-window command. - - synchronize-panes [on | off] - Duplicate input to any pane to all other panes in the - same window (only for panes that are not in any special - mode). - - utf8 [on | off] - Instructs tmux to expect UTF-8 sequences to appear in - this window. - - window-status-bell-attr attributes - Set status line attributes for windows which have a bell - alert. - - window-status-bell-bg colour - Set status line background colour for windows with a bell - alert. - - window-status-bell-fg colour - Set status line foreground colour for windows with a bell - alert. - - window-status-content-attr attributes - Set status line attributes for windows which have a con- - tent alert. - - window-status-content-bg colour - Set status line background colour for windows with a con- - tent alert. - - window-status-content-fg colour - Set status line foreground colour for windows with a con- - tent alert. - - window-status-activity-attr attributes - Set status line attributes for windows which have an - activity (or silence) alert. - - window-status-activity-bg colour - Set status line background colour for windows with an - activity alert. - - window-status-activity-fg colour - Set status line foreground colour for windows with an - activity alert. - - window-status-attr attributes - Set status line attributes for a single window. - - window-status-bg colour - Set status line background colour for a single window. - - window-status-current-attr attributes - Set status line attributes for the currently active win- - dow. - - window-status-current-bg colour - Set status line background colour for the currently - active window. - - window-status-current-fg colour - Set status line foreground colour for the currently - active window. - - window-status-current-format string - Like window-status-format, but is the format used when - the window is the current window. - - window-status-last-attr attributes - Set status line attributes for the last active window. - - window-status-last-bg colour - Set status line background colour for the last active - window. - - window-status-last-fg colour - Set status line foreground colour for the last active - window. - - window-status-fg colour - Set status line foreground colour for a single window. - - window-status-format string - Set the format in which the window is displayed in the - status line window list. See the status-left option for - details of special character sequences available. The - default is '#I:#W#F'. - - window-status-separator string - Sets the separator drawn between windows in the status - line. The default is a single space character. - - xterm-keys [on | off] - If this option is set, tmux will generate xterm(1) -style - function key sequences; these have a number included to - indicate modifiers such as Shift, Alt or Ctrl. The - default is off. - - wrap-search [on | off] - If this option is set, searches will wrap around the end - of the pane contents. The default is on. - - show-options [-gqsvw] [-t target-session | target-window] [option] - (alias: show) - Show the window options (or a single window option if given) with - -w (equivalent to show-window-options), the server options with - -s, otherwise the session options for target session. Global - session or window options are listed if -g is used. -v shows - only the option value, not the name. If -q is set, no error will - be returned if option is unset. - - show-window-options [-gv] [-t target-window] [option] - (alias: showw) - List the window options or a single option for target-window, or - the global window options if -g is used. -v shows only the - option value, not the name. - -FORMATS - Certain commands accept the -F flag with a format argument. This is a - string which controls the output format of the command. Special charac- - ter sequences are replaced as documented under the status-left option and - an additional long form is accepted. Replacement variables are enclosed - in '#{' and '}', for example '#{session_name}' is equivalent to '#S'. - Conditionals are also accepted by prefixing with '?' and separating two - alternatives with a comma; if the specified variable exists and is not - zero, the first alternative is chosen, otherwise the second is used. For - example '#{?session_attached,attached,not attached}' will include the - string 'attached' if the session is attached and the string 'not - attached' if it is unattached. - - The following variables are available, where appropriate: - - Variable name Replaced with - alternate_on If pane is in alternate screen - alternate_saved_x Saved cursor X in alternate screen - alternate_saved_y Saved cursor Y in alternate screen - buffer_sample First 50 characters from the specified - buffer - buffer_size Size of the specified buffer in bytes - client_activity Integer time client last had activity - client_activity_string String time client last had activity - client_created Integer time client created - client_created_string String time client created - client_cwd Working directory of client - client_height Height of client - client_last_session Name of the client's last session - client_prefix 1 if prefix key has been pressed - client_readonly 1 if client is readonly - client_session Name of the client's session - client_termname Terminal name of client - client_tty Pseudo terminal of client - client_utf8 1 if client supports utf8 - client_width Width of client - cursor_flag Pane cursor flag - cursor_x Cursor X position in pane - cursor_y Cursor Y position in pane - history_bytes Number of bytes in window history - history_limit Maximum window history lines - history_size Size of history in bytes - host Hostname of local host - insert_flag Pane insert flag - keypad_cursor_flag Pane keypad cursor flag - keypad_flag Pane keypad flag - line Line number in the list - mouse_any_flag Pane mouse any flag - mouse_button_flag Pane mouse button flag - mouse_standard_flag Pane mouse standard flag - mouse_utf8_flag Pane mouse UTF-8 flag - pane_active 1 if active pane - pane_current_command Current command if available - pane_current_path Current path if available - pane_dead 1 if pane is dead - pane_height Height of pane - pane_id Unique pane ID - pane_in_mode If pane is in a mode - pane_index Index of pane - pane_pid PID of first process in pane - pane_start_command Command pane started with - pane_start_path Path pane started with - pane_tabs Pane tab positions - pane_title Title of pane - pane_tty Pseudo terminal of pane - pane_width Width of pane - saved_cursor_x Saved cursor X in pane - saved_cursor_y Saved cursor Y in pane - scroll_region_lower Bottom of scroll region in pane - scroll_region_upper Top of scroll region in pane - session_attached 1 if session attached - session_created Integer time session created - session_created_string String time session created - session_group Number of session group - session_grouped 1 if session in a group - session_height Height of session - session_id Unique session ID - session_name Name of session - session_width Width of session - session_windows Number of windows in session - window_active 1 if window active - window_find_matches Matched data from the find-window command - if available - window_flags Window flags - window_height Height of window - window_id Unique window ID - window_index Index of window - window_layout Window layout description - window_name Name of window - window_panes Number of panes in window - window_width Width of window - wrap_flag Pane wrap flag - -NAMES AND TITLES - tmux distinguishes between names and titles. Windows and sessions have - names, which may be used to specify them in targets and are displayed in - the status line and various lists: the name is the tmux identifier for a - window or session. Only panes have titles. A pane's title is typically - set by the program running inside the pane and is not modified by tmux. - It is the same mechanism used to set for example the xterm(1) window - title in an X(7) window manager. Windows themselves do not have titles - - a window's title is the title of its active pane. tmux itself may set - the title of the terminal in which the client is running, see the - set-titles option. - - A session's name is set with the new-session and rename-session commands. - A window's name is set with one of: - - 1. A command argument (such as -n for new-window or new-session). - - 2. An escape sequence: - - $ printf '\033kWINDOW_NAME\033\\' - - 3. Automatic renaming, which sets the name to the active command in - the window's active pane. See the automatic-rename option. - - When a pane is first created, its title is the hostname. A pane's title - can be set via the OSC title setting sequence, for example: - - $ printf '\033]2;My Title\033\\' - -ENVIRONMENT - When the server is started, tmux copies the environment into the global - environment; in addition, each session has a session environment. When a - window is created, the session and global environments are merged. If a - variable exists in both, the value from the session environment is used. - The result is the initial environment passed to the new process. - - The update-environment session option may be used to update the session - environment from the client when a new session is created or an old reat- - tached. tmux also initialises the TMUX variable with some internal - information to allow commands to be executed from inside, and the TERM - variable with the correct terminal setting of 'screen'. - - Commands to alter and view the environment are: - - set-environment [-gru] [-t target-session] name [value] - (alias: setenv) - Set or unset an environment variable. If -g is used, the change - is made in the global environment; otherwise, it is applied to - the session environment for target-session. The -u flag unsets a - variable. -r indicates the variable is to be removed from the - environment before starting a new process. - - show-environment [-g] [-t target-session] [variable] - (alias: showenv) - Display the environment for target-session or the global environ- - ment with -g. If variable is omitted, all variables are shown. - Variables removed from the environment are prefixed with '-'. - -STATUS LINE - tmux includes an optional status line which is displayed in the bottom - line of each terminal. By default, the status line is enabled (it may be - disabled with the status session option) and contains, from left-to- - right: the name of the current session in square brackets; the window - list; the title of the active pane in double quotes; and the time and - date. - - The status line is made of three parts: configurable left and right sec- - tions (which may contain dynamic content such as the time or output from - a shell command, see the status-left, status-left-length, status-right, - and status-right-length options below), and a central window list. By - default, the window list shows the index, name and (if any) flag of the - windows present in the current session in ascending numerical order. It - may be customised with the window-status-format and - window-status-current-format options. The flag is one of the following - symbols appended to the window name: - - Symbol Meaning - * Denotes the current window. - - Marks the last window (previously selected). - # Window is monitored and activity has been detected. - ! A bell has occurred in the window. - + Window is monitored for content and it has appeared. - ~ The window has been silent for the monitor-silence - interval. - Z The window's active pane is zoomed. - - The # symbol relates to the monitor-activity and + to the monitor-content - window options. The window name is printed in inverted colours if an - alert (bell, activity or content) is present. - - The colour and attributes of the status line may be configured, the - entire status line using the status-attr, status-fg and status-bg session - options and individual windows using the window-status-attr, - window-status-fg and window-status-bg window options. - - The status line is automatically refreshed at interval if it has changed, - the interval may be controlled with the status-interval session option. - - Commands related to the status line are as follows: - - command-prompt [-I inputs] [-p prompts] [-t target-client] [template] - Open the command prompt in a client. This may be used from - inside tmux to execute commands interactively. - - If template is specified, it is used as the command. If present, - -I is a comma-separated list of the initial text for each prompt. - If -p is given, prompts is a comma-separated list of prompts - which are displayed in order; otherwise a single prompt is dis- - played, constructed from template if it is present, or ':' if - not. - - Both inputs and prompts may contain the special character - sequences supported by the status-left option. - - Before the command is executed, the first occurrence of the - string '%%' and all occurrences of '%1' are replaced by the - response to the first prompt, the second '%%' and all '%2' are - replaced with the response to the second prompt, and so on for - further prompts. Up to nine prompt responses may be replaced - ('%1' to '%9'). - - confirm-before [-p prompt] [-t target-client] command - (alias: confirm) - Ask for confirmation before executing command. If -p is given, - prompt is the prompt to display; otherwise a prompt is con- - structed from command. It may contain the special character - sequences supported by the status-left option. - - This command works only from inside tmux. - - display-message [-p] [-c target-client] [-t target-pane] [message] - (alias: display) - Display a message. If -p is given, the output is printed to std- - out, otherwise it is displayed in the target-client status line. - The format of message is described in the FORMATS section; infor- - mation is taken from target-pane if -t is given, otherwise the - active pane for the session attached to target-client. - -BUFFERS - tmux maintains a stack of paste buffers. Up to the value of the - buffer-limit option are kept; when a new buffer is added, the buffer at - the bottom of the stack is removed. Buffers may be added using copy-mode - or the set-buffer command, and pasted into a window using the - paste-buffer command. - - A configurable history buffer is also maintained for each window. By - default, up to 2000 lines are kept; this can be altered with the - history-limit option (see the set-option command above). - - The buffer commands are as follows: - - choose-buffer [-F format] [-t target-window] [template] - Put a window into buffer choice mode, where a buffer may be cho- - sen interactively from a list. After a buffer is selected, '%%' - is replaced by the buffer index in template and the result exe- - cuted as a command. If template is not given, "paste-buffer -b - '%%'" is used. For the meaning of the -F flag, see the FORMATS - section. This command works only if at least one client is - attached. - - clear-history [-t target-pane] - (alias: clearhist) - Remove and free the history for the specified pane. - - delete-buffer [-b buffer-index] - (alias: deleteb) - Delete the buffer at buffer-index, or the top buffer if not spec- - ified. - - list-buffers [-F format] - (alias: lsb) - List the global buffers. For the meaning of the -F flag, see the - FORMATS section. - - load-buffer [-b buffer-index] path - (alias: loadb) - Load the contents of the specified paste buffer from path. - - paste-buffer [-dpr] [-b buffer-index] [-s separator] [-t target-pane] - (alias: pasteb) - Insert the contents of a paste buffer into the specified pane. - If not specified, paste into the current one. With -d, also - delete the paste buffer from the stack. When output, any line- - feed (LF) characters in the paste buffer are replaced with a sep- - arator, by default carriage return (CR). A custom separator may - be specified using the -s flag. The -r flag means to do no - replacement (equivalent to a separator of LF). If -p is speci- - fied, paste bracket control codes are inserted around the buffer - if the application has requested bracketed paste mode. - - save-buffer [-a] [-b buffer-index] path - (alias: saveb) - Save the contents of the specified paste buffer to path. The -a - option appends to rather than overwriting the file. - - set-buffer [-b buffer-index] data - (alias: setb) - Set the contents of the specified buffer to data. - - show-buffer [-b buffer-index] - (alias: showb) - Display the contents of the specified buffer. - -MISCELLANEOUS - Miscellaneous commands are as follows: - - clock-mode [-t target-pane] - Display a large clock. - - if-shell [-b] [-t target-pane] shell-command command [command] - (alias: if) - Execute the first command if shell-command returns success or the - second command otherwise. Before being executed, shell-command - is expanded using the rules specified in the FORMATS section, - including those relevant to target-pane. With -b, shell-command - is run in the background. - - lock-server - (alias: lock) - Lock each client individually by running the command specified by - the lock-command option. - - run-shell -b [-t target-pane] shell-command - (alias: run) - Execute shell-command in the background without creating a win- - dow. Before being executed, shell-command is expanded using the - rules specified in the FORMATS section. With -b, the command is - run in the background. After it finishes, any output to stdout - is displayed in copy mode (in the pane specified by -t or the - current pane if omitted). If the command doesn't return success, - the exit status is also displayed. - - server-info - (alias: info) - Show server information and terminal details. - - wait-for -LSU channel - (alias: wait) - When used without options, prevents the client from exiting until - woken using wait-for -S with the same channel. When -L is used, - the channel is locked and any clients that try to lock the same - channel are made to wait until the channel is unlocked with - wait-for -U. This command only works from outside tmux. - -TERMINFO EXTENSIONS - tmux understands some extensions to terminfo(5): - - Cc, Cr Set the cursor colour. The first takes a single string argument - and is used to set the colour; the second takes no arguments and - restores the default cursor colour. If set, a sequence such as - this may be used to change the cursor colour from inside tmux: - - $ printf '\033]12;red\033\\' - - Cs, Csr - Change the cursor style. If set, a sequence such as this may be - used to change the cursor to an underline: - - $ printf '\033[4 q' - - If Csr is set, it will be used to reset the cursor style instead - of Cs. - - Ms This sequence can be used by tmux to store the current buffer in - the host terminal's selection (clipboard). See the set-clipboard - option above and the xterm(1) man page. - -CONTROL MODE - tmux offers a textual interface called control mode. This allows appli- - cations to communicate with tmux using a simple text-only protocol. - - In control mode, a client sends tmux commands or command sequences termi- - nated by newlines on standard input. Each command will produce one block - of output on standard output. An output block consists of a %begin line - followed by the output (which may be empty). The output block ends with - a %end or %error. %begin and matching %end or %error have two arguments: - an integer time (as seconds from epoch) and command number. For example: - - %begin 1363006971 2 - 0: ksh* (1 panes) [80x24] [layout b25f,80x24,0,0,2] @2 (active) - %end 1363006971 2 - - In control mode, tmux outputs notifications. A notification will never - occur inside an output block. - - The following notifications are defined: - - %exit [reason] - The tmux client is exiting immediately, either because it is not - attached to any session or an error occurred. If present, reason - describes why the client exited. - - %layout-change window-id window-layout - The layout of a window with ID window-id changed. The new layout - is window-layout. - - %output pane-id value - A window pane produced output. value escapes non-printable char- - acters and backslash as octal \xxx. - - %session-changed session-id name - The client is now attached to the session with ID session-id, - which is named name. - - %session-renamed name - The current session was renamed to name. - - %sessions-changed - A session was created or destroyed. - - %unlinked-window-add window-id - The window with ID window-id was created but is not linked to the - current session. - - %window-add window-id - The window with ID window-id was linked to the current session. - - %window-close window-id - The window with ID window-id closed. - - %window-renamed window-id name - The window with ID window-id was renamed to name. - -FILES - ~/.tmux.conf Default tmux configuration file. - /etc/tmux.conf System-wide configuration file. - -EXAMPLES - To create a new tmux session running vi(1): - - $ tmux new-session vi - - Most commands have a shorter form, known as an alias. For new-session, - this is new: - - $ tmux new vi - - Alternatively, the shortest unambiguous form of a command is accepted. - If there are several options, they are listed: - - $ tmux n - ambiguous command: n, could be: new-session, new-window, next-window - - Within an active session, a new window may be created by typing 'C-b c' - (Ctrl followed by the 'b' key followed by the 'c' key). - - Windows may be navigated with: 'C-b 0' (to select window 0), 'C-b 1' (to - select window 1), and so on; 'C-b n' to select the next window; and 'C-b - p' to select the previous window. - - A session may be detached using 'C-b d' (or by an external event such as - ssh(1) disconnection) and reattached with: - - $ tmux attach-session - - Typing 'C-b ?' lists the current key bindings in the current window; up - and down may be used to navigate the list or 'q' to exit from it. - - Commands to be run when the tmux server is started may be placed in the - ~/.tmux.conf configuration file. Common examples include: - - Changing the default prefix key: - - set-option -g prefix C-a - unbind-key C-b - bind-key C-a send-prefix - - Turning the status line off, or changing its colour: - - set-option -g status off - set-option -g status-bg blue - - Setting other options, such as the default command, or locking after 30 - minutes of inactivity: - - set-option -g default-command "exec /bin/ksh" - set-option -g lock-after-time 1800 - - Creating new key bindings: - - bind-key b set-option status - bind-key / command-prompt "split-window 'exec man %%'" - bind-key S command-prompt "new-window -n %1 'ssh %1'" - -SEE ALSO - pty(4) - -AUTHORS - Nicholas Marriott - -BSD October 19, 2013 BSD diff --git a/manual/NOTES.rst b/manual/NOTES.rst deleted file mode 100644 index 080d4bf942..0000000000 --- a/manual/NOTES.rst +++ /dev/null @@ -1,88 +0,0 @@ -=================== -Compatibility notes -=================== - -master ------- - -src: http://sourceforge.net/p/tmux/tmux-code/ci/master/tree/ - -1.8 ---- - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.8/tree/ - -1.7 ---- - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.7/tree/ - -1.6 ---- - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/ - -new-window -"""""""""" - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/cmd-new-window.c - -.. code-block:: c - - if (args_has(args, 'P')) - ctx->print(ctx, "%s:%u", s->name, wl->idx); - return (0); - -split-window -"""""""""""" - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/cmd-split-window.c - -.. code-block:: c - - if (args_has(args, 'P')) { - if (window_pane_index(new_wp, &paneidx) != 0) - fatalx("index not found"); - ctx->print(ctx, "%s:%u.%u", s->name, wl->idx, paneidx); - } - return (0); - -list-sessions -""""""""""""" - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/cmd-list-sessions.c - -.. code-block:: c - - template = "#{session_name}: #{session_windows} windows " - "(created #{session_created_string}) [#{session_width}x" - "#{session_height}]#{?session_grouped, (group ,}" - "#{session_group}#{?session_grouped,),}" - "#{?session_attached, (attached),}"; - -list-windows -"""""""""""" - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/cmd-list-windows.c - -.. code-block:: c - - template = "#{session_name}:#{window_index}: " - "#{window_name} " - "[#{window_width}x#{window_height}] " - "[layout #{window_layout}]" - "#{?window_active, (active),}"; - - -list-panes -"""""""""" - -src: http://sourceforge.net/p/tmux/tmux-code/ci/1.6/tree/cmd-list-panes.c - -.. code-block:: c - - template = "#{session_name}:#{window_index}.#{pane_index}: " - "[#{pane_width}x#{pane_height}] [history " - "#{history_size}/#{history_limit}, " - "#{history_bytes} bytes] #{pane_id}" - "#{?pane_active, (active),}#{?pane_dead, (dead),}"; diff --git a/manual/README.rst b/manual/README.rst deleted file mode 100644 index 161af64ca2..0000000000 --- a/manual/README.rst +++ /dev/null @@ -1,26 +0,0 @@ -For studying the differences between prior tmux versions to check -compatibility with legacy versions. - -Get source: - -.. code-block:: bash - - $ git clone git://git.code.sf.net/p/tmux/tmux-code tmux-tmux-code tmux - $ cd tmux - -Converted with: - -.. code-block:: bash - - $ git checkout - $ ./configure - $ make - $ groff -t -e -mandoc -Tascii tmux.1 | col -bx > manpage.txt - -repeat for versions. - -Create a git-diff style diff of version manuals: - -.. code-block:: bash - - $ diff -u 1.6 1.8 > 1_6__1_8.diff diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..2dee3574d5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,263 @@ +[project] +name = "tmuxp" +version = "1.74.0" +description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Utilities", + "Topic :: System :: Shells", +] +keywords = ["tmux", "session manager", "terminal", "ncurses"] +homepage = "http://github.com/tmux-python/tmuxp/" +readme = "README.md" +packages = [ + { include = "*", from = "src" }, +] +include = [ + { path = "CHANGES", format = "sdist" }, + { path = ".tmuxp.yaml", format = "sdist" }, + { path = "tests", format = "sdist" }, + { path = "examples", format = "sdist" }, + { path = "docs", format = "sdist" }, + { path = "conftest.py", format = "sdist" }, +] +dependencies = [ + "libtmux~=0.61.0", + "PyYAML>=6.0" +] + +[project.urls] +"Bug Tracker" = "https://github.com/tmux-python/tmuxp/issues" +Documentation = "https://tmuxp.git-pull.com" +Repository = "https://github.com/tmux-python/tmuxp" +Changes = "https://github.com/tmux-python/tmuxp/blob/master/CHANGES" + +[project.scripts] +tmuxp = 'tmuxp:cli.cli' + +[project.entry-points."tmuxp.workspace_builders"] +classic = "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + +[dependency-groups] +dev = [ + # Docs + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-argparse==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-api-style==0.0.1a35", + "sphinx-gp-highlighting==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-gp-mermaid==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "gp-libs>=0.0.19", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html + # Testing + "gp-libs>=0.0.19", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", + # Coverage + "codecov", + "coverage", + "pytest-cov", + # Lint + "ruff", + "mypy", + "types-docutils", + "types-Pygments", + "types-PyYAML", +] + +docs = [ + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-argparse==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-api-style==0.0.1a35", + "sphinx-gp-highlighting==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-gp-mermaid==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "gp-libs>=0.0.19", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html +] +testing = [ + "gp-libs>=0.0.19", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", +] +coverage =[ + "codecov", + "coverage", + "pytest-cov", +] +lint = [ + "ruff", + "mypy", + "types-docutils", + "types-Pygments", + "types-PyYAML", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.exclude-newer-package] +# git-pull packages release in lockstep with their workspaces, so a +# fresh release blocking on the 3-day cooldown blocks every +# contributor's `uv sync` until it clears. `false` exempts each +# from any `exclude-newer` constraint (global or per-user) without +# committing a date that would age into the lockfile. Mirrors the +# pattern at vcspull/pyproject.toml for libvcs. +gp-libs = false +gp-furo-theme = false +gp-sphinx = false +libtmux = false +sphinx-autodoc-api-style = false +sphinx-autodoc-argparse = false +sphinx-autodoc-docutils = false +sphinx-autodoc-fastmcp = false +sphinx-autodoc-pytest-fixtures = false +sphinx-autodoc-sphinx = false +sphinx-autodoc-typehints-gp = false +sphinx-fonts = false +sphinx-gp-highlighting = false +sphinx-gp-mermaid = false +sphinx-gp-opengraph = false +sphinx-gp-llms = false +sphinx-gp-sitemap = false +sphinx-gp-theme = false +sphinx-ux-autodoc-layout = false +sphinx-ux-badges = false + +[tool.coverage.run] +branch = true +source = [ + "tmuxp", +] +omit = [ + "tests/*", + "*/_vendor/*", + "*/_*", + "pkg/*", + "*/log.py", + "docs/_ext/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "def parse_args", + "from __future__ import annotations", + "if TYPE_CHECKING:", + "if t.TYPE_CHECKING:", +] + +[tool.mypy] +strict = true +python_version = "3.10" +files = [ + "src/", + "tests/", +] +enable_incomplete_feature = [] + +[[tool.mypy.overrides]] +module = [ + "shtab", + "IPython.*", + "ptpython.*", + "prompt_toolkit.*", + "bpython", + "aafigure", +] +ignore_missing_imports = true + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "A", # flake8-builtins + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "COM", # flake8-commas + "EM", # flake8-errmsg + "Q", # flake8-quotes + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify + "TRY", # Trycertatops + "PERF", # Perflint + "RUF", # Ruff-specific rules + "D", # pydocstyle + "FA100", # future annotations +] +ignore = [ + "COM812", # missing trailing comma, ruff format conflict +] +extend-safe-fixes = [ + "UP006", + "UP007", +] +pyupgrade.keep-runtime-typing = false + +[tool.ruff.lint.isort] +known-first-party = [ + "tmuxp", +] +combine-as-imports = true +required-imports = [ + "from __future__ import annotations", +] + +[tool.ruff.lint.flake8-builtins] +builtins-allowed-modules = [ + "types", +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"*/__init__.py" = ["F401"] +"src/tmuxp/workspace/finders.py" = ["PTH"] +"src/tmuxp/cli/*.py" = ["PTH"] +"docs/_ext/aafig.py" = ["PTH"] + +[tool.pytest.ini_options] +addopts = "--reruns=0 --tb=short --no-header --showlocals --doctest-modules" +doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" +testpaths = [ + "src/tmuxp", + "tests", + "docs", +] +filterwarnings = [ + "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", + "ignore:.*invalid escape sequence.*:SyntaxWarning:.*aafigure.*:", +] diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index 4241100e18..0000000000 --- a/requirements/base.txt +++ /dev/null @@ -1,4 +0,0 @@ -kaptan>=0.5.7 -libtmux==0.6.1 # Updated from 0.6.0 -click==6.6 -colorama diff --git a/requirements/doc.txt b/requirements/doc.txt deleted file mode 100644 index a5d11da171..0000000000 --- a/requirements/doc.txt +++ /dev/null @@ -1,8 +0,0 @@ --r ./base.txt -docutils==0.12 -requests[security] -sphinx -reportlab -alabaster -releases==1.2.1 # Updated from 1.2.0 -aafigure diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 43c1823da1..0000000000 --- a/requirements/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest==3.0.5 # Updated from 3.0.4 -pytest-rerunfailures==2.1.0 # Updated from 2.0.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 615899d1de..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -exclude = .*/,.tox,*.egg,tmuxp/_compat.py,tmuxp/__*__.py, -select = E,W,F,N - -[tool:pytest] -addopts = --rerun 5 diff --git a/setup.py b/setup.py deleted file mode 100644 index 63ebf9c34b..0000000000 --- a/setup.py +++ /dev/null @@ -1,72 +0,0 @@ -"""tmuxp lives at .""" -import sys - -from setuptools import setup -from setuptools.command.test import test as TestCommand - -about = {} -with open("tmuxp/__about__.py") as fp: - exec(fp.read(), about) - -with open('requirements/base.txt') as f: - install_reqs = [line for line in f.read().split('\n') if line] - -with open('requirements/test.txt') as f: - tests_reqs = [line for line in f.read().split('\n') if line] - -if sys.version_info[0] > 2: - readme = open('README.rst', encoding='utf-8').read() -else: - readme = open('README.rst').read() - -history = open('CHANGES').read().replace('.. :changelog:', '') - - -class PyTest(TestCommand): - user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] - - def initialize_options(self): - TestCommand.initialize_options(self) - self.pytest_args = [] - - def run_tests(self): - import pytest - errno = pytest.main(self.pytest_args) - sys.exit(errno) - - -setup( - name=about['__title__'], - version=about['__version__'], - url='http://github.com/tony/tmuxp/', - download_url='https://pypi.python.org/pypi/tmuxp', - license=about['__license__'], - author=about['__author__'], - author_email=about['__email__'], - description=about['__description__'], - long_description=readme, - packages=['tmuxp'], - include_package_data=True, - install_requires=install_reqs, - tests_require=tests_reqs, - cmdclass={'test': PyTest}, - zip_safe=False, - keywords=about['__title__'], - entry_points=dict(console_scripts=['tmuxp=tmuxp:cli.cli']), - classifiers=[ - 'Development Status :: 5 - Production/Stable', - "License :: OSI Approved :: BSD License", - "Operating System :: POSIX", - "Operating System :: MacOS :: MacOS X", - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - "Topic :: Utilities", - "Topic :: System :: Shells", - ], -) diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py new file mode 100644 index 0000000000..6356e4c242 --- /dev/null +++ b/src/tmuxp/__about__.py @@ -0,0 +1,21 @@ +"""Metadata for tmuxp package.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +__title__ = "tmuxp" +__package_name__ = "tmuxp" +__version__ = "1.74.0" +__description__ = "tmux session manager" +__email__ = "tony@git-pull.com" +__author__ = "Tony Narlock" +__github__ = "https://github.com/tmux-python/tmuxp" +__docs__ = "https://tmuxp.git-pull.com" +__tracker__ = "https://github.com/tmux-python/tmuxp/issues" +__changes__ = "https://github.com/tmux-python/tmuxp/blob/master/CHANGES" +__pypi__ = "https://pypi.org/project/tmuxp/" +__license__ = "MIT" +__copyright__ = "Copyright 2013- Tony Narlock" diff --git a/src/tmuxp/__init__.py b/src/tmuxp/__init__.py new file mode 100644 index 0000000000..78e2118ea5 --- /dev/null +++ b/src/tmuxp/__init__.py @@ -0,0 +1,24 @@ +"""tmux session manager. + +:copyright: Copyright 2013- Tony Narlock. +:license: MIT, see LICENSE for details +""" + +from __future__ import annotations + +import logging + +from . import cli, util +from .__about__ import ( + __author__, + __copyright__, + __description__, + __email__, + __license__, + __package_name__, + __title__, + __version__, +) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py new file mode 100644 index 0000000000..51a055f7f5 --- /dev/null +++ b/src/tmuxp/_compat.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import logging +import sys + +logger = logging.getLogger(__name__) + +PY3 = sys.version_info[0] == 3 +PYMINOR = sys.version_info[1] +PYPATCH = sys.version_info[2] + + +def _identity(x: object) -> object: + """Return *x* unchanged — used as a no-op decorator. + + Examples + -------- + >>> from tmuxp._compat import _identity + + Strings pass through unchanged: + + >>> _identity("hello") + 'hello' + + Integers pass through unchanged: + + >>> _identity(42) + 42 + """ + return x + + +if PY3 and PYMINOR >= 7: + breakpoint = breakpoint # noqa: A001 +else: + import pdb + + breakpoint = pdb.set_trace # noqa: A001 + + +implements_to_string = _identity diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py new file mode 100644 index 0000000000..baae40fd2c --- /dev/null +++ b/src/tmuxp/_internal/__init__.py @@ -0,0 +1,7 @@ +"""Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py new file mode 100644 index 0000000000..a9b350016f --- /dev/null +++ b/src/tmuxp/_internal/colors.py @@ -0,0 +1,904 @@ +"""Color output utilities for tmuxp CLI. + +This module provides semantic color utilities following patterns from vcspull +and CPython's _colorize module. It includes low-level ANSI styling functions +and high-level semantic color utilities. + +Examples +-------- +Basic usage with automatic TTY detection (AUTO mode is the default). +In a TTY, colored text is returned; otherwise plain text: + +>>> colors = Colors() + +Force colors on or off: + +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' + +>>> colors = Colors(ColorMode.NEVER) +>>> colors.success("loaded") +'loaded' + +Environment variables NO_COLOR and FORCE_COLOR are respected. +NO_COLOR takes highest priority (disables even in ALWAYS mode): + +>>> monkeypatch.setenv("NO_COLOR", "1") +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") +'loaded' + +FORCE_COLOR enables colors in AUTO mode even without TTY: + +>>> import sys +>>> monkeypatch.delenv("NO_COLOR", raising=False) +>>> monkeypatch.setenv("FORCE_COLOR", "1") +>>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) +>>> colors = Colors(ColorMode.AUTO) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' +""" + +from __future__ import annotations + +import enum +import logging +import os +import re +import sys +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColour: TypeAlias = int | tuple[int, int, int] | str + + +class ColorMode(enum.Enum): + """Color output modes for CLI. + + Examples + -------- + >>> ColorMode.AUTO.value + 'auto' + >>> ColorMode.ALWAYS.value + 'always' + >>> ColorMode.NEVER.value + 'never' + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +class Colors: + r"""Semantic color utilities for CLI output. + + Provides semantic color methods (success, warning, error, etc.) that + conditionally apply ANSI colors based on the color mode and environment. + + Parameters + ---------- + mode : ColorMode + Color mode to use. Default is AUTO which detects TTY. + + Attributes + ---------- + SUCCESS : str + Color name for success messages (green) + WARNING : str + Color name for warning messages (yellow) + ERROR : str + Color name for error messages (red) + INFO : str + Color name for informational messages (cyan) + HEADING : str + Color name for section headers (bright_cyan) + HIGHLIGHT : str + Color name for highlighted/important text (magenta) + MUTED : str + Color name for subdued/secondary text (blue) + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("session loaded") + 'session loaded' + >>> colors.error("failed to load") + 'failed to load' + + >>> colors = Colors(ColorMode.ALWAYS) + >>> result = colors.success("ok") + + Check that result contains ANSI escape codes: + + >>> "\033[" in result + True + """ + + # Semantic color names (used with style()) + SUCCESS = "green" # Success, loaded, up-to-date + WARNING = "yellow" # Warnings, changes needed + ERROR = "red" # Errors, failures + INFO = "cyan" # Information, paths, supplementary (L2) + HEADING = "bright_cyan" # Section headers (L0) - brighter than INFO + HIGHLIGHT = "magenta" # Important labels, session names (L1) + MUTED = "blue" # Subdued info, secondary text (L3) + + def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: + """Initialize color manager. + + Parameters + ---------- + mode : ColorMode + Color mode to use (auto, always, never). Default is AUTO. + + Examples + -------- + >>> colors = Colors() + >>> colors.mode + + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._enabled + False + """ + self.mode = mode + self._enabled = self._should_enable() + + def _should_enable(self) -> bool: + """Determine if color should be enabled. + + Follows CPython-style precedence: + 1. NO_COLOR env var (any value) -> disable + 2. ColorMode.NEVER -> disable + 3. ColorMode.ALWAYS -> enable + 4. FORCE_COLOR env var (any value) -> enable + 5. TTY check -> enable if stdout is a terminal + + Returns + ------- + bool + True if colors should be enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors._should_enable() + False + """ + # NO_COLOR takes highest priority (standard convention) + if os.environ.get("NO_COLOR"): + return False + + if self.mode == ColorMode.NEVER: + return False + if self.mode == ColorMode.ALWAYS: + return True + + # AUTO mode: check FORCE_COLOR then TTY + if os.environ.get("FORCE_COLOR"): + return True + + return sys.stdout.isatty() + + def _colorize( + self, text: str, fg: str, bold: bool = False, dim: bool = False + ) -> str: + """Apply color using style() function. + + Parameters + ---------- + text : str + Text to colorize. + fg : str + Foreground color name (e.g., "green", "red"). + bold : bool + Whether to apply bold style. Default is False. + dim : bool + Whether to apply dim/faint style. Default is False. + + Returns + ------- + str + Colorized text if enabled, plain text otherwise. + + Examples + -------- + When colors are enabled, applies ANSI escape codes: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> colors._colorize("test", "green") # doctest: +ELLIPSIS + '...' + + When colors are disabled, returns plain text: + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._colorize("test", "green") + 'test' + """ + if self._enabled: + return style(text, fg=fg, bold=bold, dim=dim) + return text + + def success(self, text: str, bold: bool = False) -> str: + """Format text as success (green). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("loaded") + 'loaded' + """ + return self._colorize(text, self.SUCCESS, bold) + + def warning(self, text: str, bold: bool = False) -> str: + """Format text as warning (yellow). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.warning("check config") + 'check config' + """ + return self._colorize(text, self.WARNING, bold) + + def error(self, text: str, bold: bool = False) -> str: + """Format text as error (red). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.error("failed") + 'failed' + """ + return self._colorize(text, self.ERROR, bold) + + def info(self, text: str, bold: bool = False) -> str: + """Format text as info (cyan). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.info("/path/to/config.yaml") + '/path/to/config.yaml' + """ + return self._colorize(text, self.INFO, bold) + + def highlight(self, text: str, bold: bool = True) -> str: + """Format text as highlighted (magenta, bold by default). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is True. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.highlight("my-session") + 'my-session' + """ + return self._colorize(text, self.HIGHLIGHT, bold) + + def muted(self, text: str) -> str: + """Format text as muted (blue). + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.muted("(optional)") + '(optional)' + """ + return self._colorize(text, self.MUTED, bold=False) + + def heading(self, text: str) -> str: + """Format text as a section heading (bright cyan, bold). + + Used for section headers like 'Local workspaces:' or 'Global workspaces:'. + Uses bright_cyan to visually distinguish from info() which uses cyan. + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.heading("Local workspaces:") + 'Local workspaces:' + """ + return self._colorize(text, self.HEADING, bold=True) + + # Formatting helpers for structured output + + def format_label(self, label: str) -> str: + """Format a label (key in key:value pair). + + Parameters + ---------- + label : str + Label text to format. + + Returns + ------- + str + Highlighted label text (bold magenta when colors enabled). + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_label("tmux path") + 'tmux path' + """ + return self.highlight(label, bold=True) + + def format_path(self, path: str) -> str: + """Format a file path with info color. + + Parameters + ---------- + path : str + Path string to format. + + Returns + ------- + str + Cyan-colored path when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_path("/usr/bin/tmux") + '/usr/bin/tmux' + """ + return self.info(path) + + def format_version(self, version: str) -> str: + """Format a version string. + + Parameters + ---------- + version : str + Version string to format. + + Returns + ------- + str + Cyan-colored version when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_version("3.2a") + '3.2a' + """ + return self.info(version) + + def format_separator(self, length: int = 25) -> str: + """Format a visual separator line. + + Parameters + ---------- + length : int + Length of the separator line. Default is 25. + + Returns + ------- + str + Muted (blue) separator line when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_separator() + '-------------------------' + >>> colors.format_separator(10) + '----------' + """ + return self.muted("-" * length) + + def format_rule(self, width: int = 40, char: str = "─") -> str: + """Format a horizontal rule using Unicode box-drawing characters. + + A richer alternative to ``format_separator()`` which uses plain hyphens. + + Parameters + ---------- + width : int + Number of characters. Default is 40. + char : str + Character to repeat. Default is ``"─"`` (U+2500). + + Returns + ------- + str + Muted (blue) rule when colors enabled, plain rule otherwise. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_rule(10) + '──────────' + >>> colors.format_rule(5, char="=") + '=====' + """ + return self.muted(char * width) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + Parameters + ---------- + key : str + Key/label to highlight. + value : str + Value to display (not colorized, allows caller to format). + + Returns + ------- + str + Formatted "key: value" string with highlighted key. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_kv("tmux version", "3.2a") + 'tmux version: 3.2a' + """ + return f"{self.format_label(key)}: {value}" + + def format_tmux_option(self, line: str) -> str: + """Format tmux option line with syntax highlighting. + + Handles tmux show-options output formats: + - "key value" (space-separated) + - "key=value" (equals-separated) + - "key[index] value" (array-indexed options) + - "key" (empty array options with no value) + + Parameters + ---------- + line : str + Option line to format. + + Returns + ------- + str + Formatted line with highlighted key and info-colored value. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_tmux_option("status on") + 'status on' + >>> colors.format_tmux_option("base-index=1") + 'base-index=1' + >>> colors.format_tmux_option("pane-colours") + 'pane-colours' + >>> colors.format_tmux_option('status-format[0] "#[align=left]"') + 'status-format[0] "#[align=left]"' + """ + # Handle "key value" format (space-separated) - check first since values + # may contain '=' (e.g., status-format[0] "#[align=left]") + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Handle key=value format (only for single-token lines) + if "=" in line: + key, val = line.split("=", 1) + return f"{self.highlight(key, bold=False)}={self.info(val)}" + + # Single word = key with no value (empty array option like pane-colours) + if len(parts) == 1 and parts[0]: + return self.highlight(parts[0], bold=False) + + # Empty or unparseable - return as-is + return line + + +def get_color_mode(color_arg: str | None = None) -> ColorMode: + """Convert CLI argument string to ColorMode enum. + + Parameters + ---------- + color_arg : str | None + Color mode argument from CLI (auto, always, never). + None defaults to AUTO. + + Returns + ------- + ColorMode + The determined color mode. Invalid values return AUTO. + + Examples + -------- + >>> get_color_mode(None) + + >>> get_color_mode("always") + + >>> get_color_mode("NEVER") + + >>> get_color_mode("invalid") + + """ + if color_arg is None: + return ColorMode.AUTO + + try: + return ColorMode(color_arg.lower()) + except ValueError: + return ColorMode.AUTO + + +# ANSI styling utilities (originally from click, via utils.py) + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") +ANSI_SEQ_RE = _ansi_re + + +def strip_ansi(value: str) -> str: + r"""Clear ANSI escape codes from a string value. + + Parameters + ---------- + value : str + String potentially containing ANSI escape codes. + + Returns + ------- + str + String with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("plain text") + 'plain text' + """ + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: int | tuple[int, int, int] | str, + offset: int = 0, +) -> str: + """Convert color specification to ANSI escape code number. + + Parameters + ---------- + color : int | tuple[int, int, int] | str + Color as 256-color index, RGB tuple, or color name. + offset : int + Offset for background colors (10 for bg). + + Returns + ------- + str + ANSI escape code parameters. + + Examples + -------- + Color name returns base ANSI code: + + >>> _interpret_color("red") + '31' + + 256-color index returns extended format: + + >>> _interpret_color(196) + '38;5;196' + + RGB tuple returns 24-bit format: + + >>> _interpret_color((255, 128, 0)) + '38;2;255;128;0' + """ + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + if len(color) != 3: + msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" + raise ValueError(msg) + r, g, b = color + for i, component in enumerate((r, g, b)): + if not isinstance(component, int): + msg = ( + f"RGB values must be integers, " + f"got {type(component).__name__} at index {i}" + ) + raise TypeError(msg) + if not 0 <= component <= 255: + msg = f"RGB values must be 0-255, got {component} at index {i}" + raise ValueError(msg) + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +class UnknownStyleColor(Exception): + """Raised when encountering an unknown terminal style color. + + Examples + -------- + >>> try: + ... raise UnknownStyleColor("invalid_color") + ... except UnknownStyleColor as e: + ... "invalid_color" in str(e) + True + """ + + def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: + return super().__init__(f"Unknown color {color!r}", *args, **kwargs) + + +def style( + text: t.Any, + fg: CLIColour | None = None, + bg: CLIColour | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + r"""Apply ANSI styling to text. + + Credit: click. + + Parameters + ---------- + text : Any + Text to style (will be converted to str). + fg : CLIColour | None + Foreground color (name, 256-index, or RGB tuple). + bg : CLIColour | None + Background color. + bold : bool | None + Apply bold style. + dim : bool | None + Apply dim style. + underline : bool | None + Apply underline style. + overline : bool | None + Apply overline style. + italic : bool | None + Apply italic style. + blink : bool | None + Apply blink style. + reverse : bool | None + Apply reverse video style. + strikethrough : bool | None + Apply strikethrough style. + reset : bool + Append reset code at end. Default True. + + Returns + ------- + str + Styled text with ANSI escape codes. + + Examples + -------- + >>> style("hello", fg="green") # doctest: +ELLIPSIS + '\x1b[32m...' + >>> "hello" in style("hello", fg="green") + True + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg or fg == 0: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=fg) from None + + if bg or bg == 0: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=bg) from None + + if bold: + bits.append("\033[1m") + if dim: + bits.append("\033[2m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + r"""Remove ANSI styling information from a string. + + Usually it's not necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + Credit: click. + + Parameters + ---------- + text : str + Text to remove style information from. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> unstyle("\033[32mgreen\033[0m") + 'green' + """ + return strip_ansi(text) + + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + r"""Assemble help text with optional example sections. + + Parameters + ---------- + intro : str + The introductory description text. + example_blocks : sequence of (heading, commands) tuples + Each tuple contains an optional heading and a sequence of example commands. + If heading is None, the section is titled "examples:". + If heading is provided, it becomes the section title (without "examples:"). + + Returns + ------- + str + Formatted description with examples. + + Examples + -------- + >>> from tmuxp._internal.colors import build_description + >>> build_description("My tool.", [(None, ["mytool run"])]) + 'My tool.\n\nexamples:\n mytool run' + + >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) + 'My tool.\n\nsync examples:\n mytool sync repo' + + >>> build_description("", [(None, ["cmd"])]) + 'examples:\n cmd' + """ + import textwrap + + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading} examples:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py new file mode 100644 index 0000000000..6da248dea7 --- /dev/null +++ b/src/tmuxp/_internal/config_reader.py @@ -0,0 +1,212 @@ +"""Configuration parser for YAML and JSON files.""" + +from __future__ import annotations + +import json +import logging +import pathlib +import typing as t + +import yaml + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + FormatLiteral = t.Literal["json", "yaml"] + + RawConfigData: TypeAlias = dict[t.Any, t.Any] + + +class ConfigReader: + r"""Parse string data (YAML and JSON) into a dictionary. + + >>> cfg = ConfigReader({ "session_name": "my session" }) + >>> cfg.dump("yaml") + 'session_name: my session\n' + >>> cfg.dump("json") + '{\n "session_name": "my session"\n}' + """ + + def __init__(self, content: RawConfigData) -> None: + self.content = content + + @staticmethod + def _load(fmt: FormatLiteral, content: str) -> dict[str, t.Any]: + """Load raw config data and directly return it. + + >>> ConfigReader._load("json", '{ "session_name": "my session" }') + {'session_name': 'my session'} + + >>> ConfigReader._load("yaml", 'session_name: my session') + {'session_name': 'my session'} + """ + if fmt == "yaml": + return t.cast( + "dict[str, t.Any]", + yaml.load( + content, + Loader=yaml.SafeLoader, + ), + ) + if fmt == "json": + return t.cast("dict[str, t.Any]", json.loads(content)) + msg = f"{fmt} not supported in configuration" + raise NotImplementedError(msg) + + @classmethod + def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: + """Load raw config data into a ConfigReader instance (to dump later). + + >>> cfg = ConfigReader.load("json", '{ "session_name": "my session" }') + >>> cfg + + >>> cfg.content + {'session_name': 'my session'} + + >>> cfg = ConfigReader.load("yaml", 'session_name: my session') + >>> cfg + + >>> cfg.content + {'session_name': 'my session'} + """ + return cls( + content=cls._load( + fmt=fmt, + content=content, + ), + ) + + @classmethod + def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: + r"""Load data from file path directly to dictionary. + + **YAML file** + + *For demonstration only,* create a YAML file: + + >>> yaml_file = tmp_path / 'my_config.yaml' + >>> yaml_file.write_text('session_name: my session', encoding='utf-8') + 24 + + *Read YAML file*: + + >>> ConfigReader._from_file(yaml_file) + {'session_name': 'my session'} + + **JSON file** + + *For demonstration only,* create a JSON file: + + >>> json_file = tmp_path / 'my_config.json' + >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') + 30 + + *Read JSON file*: + + >>> ConfigReader._from_file(json_file) + {'session_name': 'my session'} + """ + assert isinstance(path, pathlib.Path) + logger.debug("loading config", extra={"tmux_config_path": str(path)}) + content = path.open(encoding="utf-8").read() + + if path.suffix in {".yaml", ".yml"}: + fmt: FormatLiteral = "yaml" + elif path.suffix == ".json": + fmt = "json" + else: + msg = f"{path.suffix} not supported in {path}" + raise NotImplementedError(msg) + + return cls._load( + fmt=fmt, + content=content, + ) + + @classmethod + def from_file(cls, path: pathlib.Path) -> ConfigReader: + r"""Load data from file path. + + **YAML file** + + *For demonstration only,* create a YAML file: + + >>> yaml_file = tmp_path / 'my_config.yaml' + >>> yaml_file.write_text('session_name: my session', encoding='utf-8') + 24 + + *Read YAML file*: + + >>> cfg = ConfigReader.from_file(yaml_file) + >>> cfg + + + >>> cfg.content + {'session_name': 'my session'} + + **JSON file** + + *For demonstration only,* create a JSON file: + + >>> json_file = tmp_path / 'my_config.json' + >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') + 30 + + *Read JSON file*: + + >>> cfg = ConfigReader.from_file(json_file) + >>> cfg + + + >>> cfg.content + {'session_name': 'my session'} + """ + return cls(content=cls._from_file(path=path)) + + @staticmethod + def _dump( + fmt: FormatLiteral, + content: RawConfigData, + indent: int = 2, + **kwargs: t.Any, + ) -> str: + r"""Dump directly. + + >>> ConfigReader._dump("yaml", { "session_name": "my session" }) + 'session_name: my session\n' + + >>> ConfigReader._dump("json", { "session_name": "my session" }) + '{\n "session_name": "my session"\n}' + """ + if fmt == "yaml": + return yaml.dump( + content, + indent=2, + default_flow_style=False, + Dumper=yaml.SafeDumper, + ) + if fmt == "json": + return json.dumps( + content, + indent=2, + ) + msg = f"{fmt} not supported in config" + raise NotImplementedError(msg) + + def dump(self, fmt: FormatLiteral, indent: int = 2, **kwargs: t.Any) -> str: + r"""Dump via ConfigReader instance. + + >>> cfg = ConfigReader({ "session_name": "my session" }) + >>> cfg.dump("yaml") + 'session_name: my session\n' + >>> cfg.dump("json") + '{\n "session_name": "my session"\n}' + """ + return self._dump( + fmt=fmt, + content=self.content, + indent=indent, + **kwargs, + ) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py new file mode 100644 index 0000000000..8fa0cec972 --- /dev/null +++ b/src/tmuxp/_internal/private_path.py @@ -0,0 +1,143 @@ +"""Privacy-aware path utilities for hiding sensitive directory information. + +This module provides utilities for masking user home directories in path output, +useful for logging, debugging, and displaying paths without exposing PII. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + PrivatePathBase = pathlib.Path +else: + PrivatePathBase = type(pathlib.Path()) + + +class PrivatePath(PrivatePathBase): + """Path subclass that hides the user's home directory in textual output. + + The class behaves like :class:`pathlib.Path`, but normalizes string and + representation output to replace the current user's home directory with + ``~``. This is useful when logging or displaying paths that should not leak + potentially sensitive information. + + Examples + -------- + >>> from pathlib import Path + >>> home = Path.home() + + >>> PrivatePath(home) + PrivatePath('~') + + >>> PrivatePath(home / "projects" / "tmuxp") + PrivatePath('~/projects/tmuxp') + + >>> str(PrivatePath("/tmp/example")) + '/tmp/example' + + >>> f'config: {PrivatePath(home / ".tmuxp" / "config.yaml")}' # doctest: +ELLIPSIS + 'config: ~/.tmuxp/config.yaml' + """ + + def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath: + """Create a new PrivatePath instance.""" + return super().__new__(cls, *args, **kwargs) + + @classmethod + def _collapse_home(cls, value: str) -> str: + """Collapse the user's home directory to ``~`` in ``value``. + + Parameters + ---------- + value : str + Path string to process + + Returns + ------- + str + Path with home directory replaced by ``~`` if applicable + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> PrivatePath._collapse_home(home) + '~' + >>> PrivatePath._collapse_home(home + "/projects") + '~/projects' + >>> PrivatePath._collapse_home("/tmp/test") + '/tmp/test' + >>> PrivatePath._collapse_home("~/already/collapsed") + '~/already/collapsed' + """ + if value.startswith("~"): + return value + + home = str(pathlib.Path.home()) + if value == home: + return "~" + + separators = {os.sep} + if os.altsep: + separators.add(os.altsep) + + for sep in separators: + home_with_sep = home + sep + if value.startswith(home_with_sep): + return "~" + value[len(home) :] + + return value + + def __str__(self) -> str: + """Return string representation with home directory collapsed to ~.""" + original = pathlib.Path.__str__(self) + return self._collapse_home(original) + + def __repr__(self) -> str: + """Return repr with home directory collapsed to ~.""" + return f"{self.__class__.__name__}({str(self)!r})" + + +def collapse_home_in_string(text: str) -> str: + """Collapse home directory paths within a colon-separated string. + + Useful for processing PATH-like environment variables that may contain + multiple paths, some of which are under the user's home directory. + + Parameters + ---------- + text : str + String potentially containing paths separated by colons (or semicolons + on Windows) + + Returns + ------- + str + String with home directory paths collapsed to ``~`` + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> collapse_home_in_string(f"{home}/.local/bin:/usr/bin") # doctest: +ELLIPSIS + '~/.local/bin:/usr/bin' + >>> collapse_home_in_string("/usr/bin:/bin") + '/usr/bin:/bin' + >>> path_str = f"{home}/bin:{home}/.cargo/bin:/usr/bin" + >>> collapse_home_in_string(path_str) # doctest: +ELLIPSIS + '~/bin:~/.cargo/bin:/usr/bin' + """ + # Handle both Unix (:) and Windows (;) path separators + separator = ";" if os.name == "nt" else ":" + parts = text.split(separator) + collapsed = [PrivatePath._collapse_home(part) for part in parts] + return separator.join(collapsed) + + +__all__ = ["PrivatePath", "collapse_home_in_string"] diff --git a/src/tmuxp/_internal/types.py b/src/tmuxp/_internal/types.py new file mode 100644 index 0000000000..41498cebd0 --- /dev/null +++ b/src/tmuxp/_internal/types.py @@ -0,0 +1,40 @@ +"""Internal, :const:`typing.TYPE_CHECKING` guarded :term:`typings `. + +These are _not_ to be imported at runtime as `typing_extensions` is not +bundled with tmuxp. Usage example: + +>>> import typing as t + +>>> if t.TYPE_CHECKING: +... from tmuxp._internal.types import PluginConfigSchema +... +""" + +from __future__ import annotations + +import logging +import typing as t +from typing import TypedDict + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import NotRequired + else: + from typing_extensions import NotRequired + + +class PluginConfigSchema(TypedDict): + plugin_name: NotRequired[str] + tmux_min_version: NotRequired[str] + tmux_max_version: NotRequired[str] + tmux_version_incompatible: NotRequired[list[str]] + libtmux_min_version: NotRequired[str] + libtmux_max_version: NotRequired[str] + libtmux_version_incompatible: NotRequired[list[str]] + tmuxp_min_version: NotRequired[str] + tmuxp_max_version: NotRequired[str] + tmuxp_version_incompatible: NotRequired[list[str]] diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py new file mode 100644 index 0000000000..860a9200cb --- /dev/null +++ b/src/tmuxp/cli/__init__.py @@ -0,0 +1,377 @@ +"""CLI utilities for tmuxp.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import typing as t + +from libtmux.__about__ import __version__ as libtmux_version +from libtmux.common import has_minimum_version +from libtmux.exc import TmuxCommandNotFound + +from tmuxp import exc +from tmuxp.__about__ import __version__ +from tmuxp.log import setup_logger + +from ._colors import build_description +from ._formatter import TmuxpHelpFormatter, create_themed_formatter +from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser +from .debug_info import ( + DEBUG_INFO_DESCRIPTION, + CLIDebugInfoNamespace, + command_debug_info, + create_debug_info_subparser, +) +from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser +from .freeze import ( + FREEZE_DESCRIPTION, + CLIFreezeNamespace, + command_freeze, + create_freeze_subparser, +) +from .import_config import ( + IMPORT_DESCRIPTION, + command_import_teamocil, + command_import_tmuxinator, + create_import_subparser, +) +from .load import ( + LOAD_DESCRIPTION, + CLILoadNamespace, + command_load, + create_load_subparser, +) +from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .search import ( + SEARCH_DESCRIPTION, + CLISearchNamespace, + command_search, + create_search_subparser, +) +from .shell import ( + SHELL_DESCRIPTION, + CLIShellNamespace, + command_shell, + create_shell_subparser, +) +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +CLI_DESCRIPTION = build_description( + """ + tmuxp - tmux session manager. + + Manage and launch tmux sessions from YAML/JSON workspace files. + """, + ( + ( + "load", + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + ], + ), + ( + "freeze", + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + ], + ), + ( + "ls", + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + "tmuxp ls --json", + ], + ), + ( + "search", + [ + "tmuxp search dev", + "tmuxp search name:myproject", + "tmuxp search -i DEV", + "tmuxp search --json dev", + ], + ), + ( + "shell", + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + ], + ), + ( + "convert", + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + ], + ), + ( + "import", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ( + "edit", + [ + "tmuxp edit myproject", + ], + ), + ( + "debug-info", + [ + "tmuxp debug-info", + "tmuxp debug-info --json", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import pathlib + from typing import TypeAlias + + CLIVerbosity: TypeAlias = t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + CLIColorMode: TypeAlias = t.Literal["auto", "always", "never"] + CLISubparserName: TypeAlias = t.Literal[ + "ls", + "load", + "freeze", + "convert", + "edit", + "import", + "search", + "shell", + "debug-info", + ] + CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] + + +def create_parser() -> argparse.ArgumentParser: + """Create CLI :class:`argparse.ArgumentParser` for tmuxp.""" + # Use factory to create themed formatter with auto-detected color mode + # This respects NO_COLOR, FORCE_COLOR env vars and TTY detection + formatter_class = create_themed_formatter() + + parser = argparse.ArgumentParser( + prog="tmuxp", + description=CLI_DESCRIPTION, + formatter_class=formatter_class, + ) + parser.add_argument( + "--version", + "-V", + action="version", + version=f"%(prog)s {__version__}, libtmux {libtmux_version}", + ) + parser.add_argument( + "--log-level", + action="store", + metavar="log-level", + default="warning", + choices=["debug", "info", "warning", "error", "critical"], + help='log level (debug, info, warning, error, critical) (default "warning")', + ) + parser.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + help="when to use colors: auto (default), always, or never", + ) + subparsers = parser.add_subparsers(dest="subparser_name") + load_parser = subparsers.add_parser( + "load", + help="load tmuxp workspaces", + description=LOAD_DESCRIPTION, + formatter_class=formatter_class, + ) + create_load_subparser(load_parser) + shell_parser = subparsers.add_parser( + "shell", + help="launch python shell for tmux server, session, window and pane", + description=SHELL_DESCRIPTION, + formatter_class=formatter_class, + ) + create_shell_subparser(shell_parser) + import_parser = subparsers.add_parser( + "import", + help="import workspaces from teamocil and tmuxinator.", + description=IMPORT_DESCRIPTION, + formatter_class=formatter_class, + ) + create_import_subparser(import_parser) + + convert_parser = subparsers.add_parser( + "convert", + help="convert workspace files between yaml and json.", + description=CONVERT_DESCRIPTION, + formatter_class=formatter_class, + ) + create_convert_subparser(convert_parser) + + debug_info_parser = subparsers.add_parser( + "debug-info", + help="print out all diagnostic info", + description=DEBUG_INFO_DESCRIPTION, + formatter_class=formatter_class, + ) + create_debug_info_subparser(debug_info_parser) + + ls_parser = subparsers.add_parser( + "ls", + help="list workspaces in tmuxp directory", + description=LS_DESCRIPTION, + formatter_class=formatter_class, + ) + create_ls_subparser(ls_parser) + + search_parser = subparsers.add_parser( + "search", + help="search workspace files by name, session, path, or content", + description=SEARCH_DESCRIPTION, + formatter_class=formatter_class, + ) + create_search_subparser(search_parser) + + edit_parser = subparsers.add_parser( + "edit", + help="run $EDITOR on workspace file", + description=EDIT_DESCRIPTION, + formatter_class=formatter_class, + ) + create_edit_subparser(edit_parser) + + freeze_parser = subparsers.add_parser( + "freeze", + help="freeze a live tmux session to a tmuxp workspace file", + description=FREEZE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_freeze_subparser(freeze_parser) + + return parser + + +class CLINamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp root-level CLI.""" + + log_level: CLIVerbosity + color: CLIColorMode + subparser_name: CLISubparserName + import_subparser_name: CLIImportSubparserName | None + version: bool + + +ns = CLINamespace() + + +def cli(_args: list[str] | None = None) -> None: + """Manage tmux sessions. + + Pass the "--help" argument to any command to see detailed help. + See detailed documentation and examples at: + http://tmuxp.git-pull.com/ + """ + try: + has_minimum_version() + except TmuxCommandNotFound: + tmuxp_echo("tmux not found. tmuxp requires you install tmux first.") + sys.exit() + except exc.TmuxpException as e: + tmuxp_echo(str(e)) + sys.exit() + + parser = create_parser() + args = parser.parse_args(_args, namespace=ns) + + setup_logger(level=args.log_level.upper()) + + if args.subparser_name is None: + parser.print_help() + return + if args.subparser_name == "load": + command_load( + args=CLILoadNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "shell": + command_shell( + args=CLIShellNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "import": + import_subparser_name = getattr(args, "import_subparser_name", None) + if import_subparser_name is None: + parser.print_help() + return + if import_subparser_name == "teamocil": + command_import_teamocil( + workspace_file=args.workspace_file, + parser=parser, + color=args.color, + ) + elif import_subparser_name == "tmuxinator": + command_import_tmuxinator( + workspace_file=args.workspace_file, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "convert": + command_convert( + workspace_file=args.workspace_file, + answer_yes=args.answer_yes, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "debug-info": + command_debug_info( + args=CLIDebugInfoNamespace(**vars(args)), + parser=parser, + ) + + elif args.subparser_name == "edit": + command_edit( + workspace_file=args.workspace_file, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "freeze": + command_freeze( + args=CLIFreezeNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "ls": + command_ls( + args=CLILsNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "search": + command_search( + args=CLISearchNamespace(**vars(args)), + parser=parser, + ) + + +def startup(config_dir: pathlib.Path) -> None: + """ + Initialize CLI. + + Parameters + ---------- + str : get_workspace_dir(): Config directory to search + """ + if not os.path.exists(config_dir): + os.makedirs(config_dir) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py new file mode 100644 index 0000000000..31dab82076 --- /dev/null +++ b/src/tmuxp/cli/_colors.py @@ -0,0 +1,38 @@ +"""Backward-compatible re-exports from _internal.colors. + +This module re-exports color utilities from their new location in _internal.colors +for backward compatibility with existing imports. + +.. deprecated:: + Import directly from tmuxp._internal.colors instead. +""" + +from __future__ import annotations + +import logging + +from tmuxp._internal.colors import ( + ANSI_SEQ_RE, + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + strip_ansi, + style, + unstyle, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "ANSI_SEQ_RE", + "ColorMode", + "Colors", + "UnknownStyleColor", + "build_description", + "get_color_mode", + "strip_ansi", + "style", + "unstyle", +] diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py new file mode 100644 index 0000000000..2296c37bf5 --- /dev/null +++ b/src/tmuxp/cli/_formatter.py @@ -0,0 +1,377 @@ +"""Custom help formatter for tmuxp CLI with colorized examples. + +This module provides a custom argparse formatter that colorizes example +sections in help output, similar to vcspull's formatter. + +Examples +-------- +>>> from tmuxp.cli._formatter import TmuxpHelpFormatter +>>> TmuxpHelpFormatter # doctest: +ELLIPSIS + +""" + +from __future__ import annotations + +import argparse +import logging +import re +import typing as t + +logger = logging.getLogger(__name__) + +# Options that expect a value (set externally or via --option=value) +OPTIONS_EXPECTING_VALUE = frozenset( + { + "-f", + "--file", + "-s", + "--socket-name", + "-S", + "--socket-path", + "-L", + "--log-level", + "-c", + "--command", + "-t", + "--target", + "-o", + "--output", + # Note: -d is --detached (flag-only), not a value option + "--color", + "-w", + "--workspace", + } +) + +# Standalone flag options (no value) +OPTIONS_FLAG_ONLY = frozenset( + { + "-h", + "--help", + "-V", + "--version", + "-y", + "--yes", + "-n", + "--no", + "-d", + "--detached", + "-2", + "-8", + "-a", + "--append", + "--json", + "--raw", + } +) + + +class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Help formatter with colorized examples for tmuxp CLI. + + This formatter extends RawDescriptionHelpFormatter to preserve formatting + of description text while adding syntax highlighting to example sections. + + The formatter uses a `_theme` attribute (set externally) to apply colors. + If no theme is set, the formatter falls back to plain text output. + + Examples + -------- + >>> formatter = TmuxpHelpFormatter("tmuxp") + >>> formatter # doctest: +ELLIPSIS + <...TmuxpHelpFormatter object at ...> + """ + + # Theme for colorization, set by create_themed_formatter() or externally + _theme: HelpTheme | None = None + + def _fill_text(self, text: str, width: int, indent: str) -> str: + """Fill text, colorizing examples sections if theme is available. + + Parameters + ---------- + text : str + Text to format. + width : int + Maximum line width. + indent : str + Indentation prefix. + + Returns + ------- + str + Formatted text, with colorized examples if theme is set. + + Examples + -------- + Without theme, returns text via parent formatter: + + >>> formatter = TmuxpHelpFormatter("test") + >>> formatter._fill_text("hello", 80, "") + 'hello' + """ + theme = getattr(self, "_theme", None) + if not text or theme is None: + return super()._fill_text(text, width, indent) + + lines = text.splitlines(keepends=True) + formatted_lines: list[str] = [] + in_examples_block = False + expect_value = False + + for line in lines: + if line.strip() == "": + in_examples_block = False + expect_value = False + formatted_lines.append(f"{indent}{line}") + continue + + has_newline = line.endswith("\n") + stripped_line = line.rstrip("\n") + leading_length = len(stripped_line) - len(stripped_line.lstrip(" ")) + leading = stripped_line[:leading_length] + content = stripped_line[leading_length:] + content_lower = content.lower() + # Recognize example section headings: + # - "examples:" starts the examples block + # - "X examples:" or "X:" are sub-section headings within examples + is_examples_start = content_lower == "examples:" + is_category_in_block = ( + in_examples_block and content.endswith(":") and not content[0].isspace() + ) + is_section_heading = ( + content_lower.endswith("examples:") or is_category_in_block + ) and not is_examples_start + + if is_section_heading or is_examples_start: + formatted_content = f"{theme.heading}{content}{theme.reset}" + in_examples_block = True + expect_value = False + elif in_examples_block: + colored_content = self._colorize_example_line( + content, + theme=theme, + expect_value=expect_value, + ) + expect_value = colored_content.expect_value + formatted_content = colored_content.text + else: + formatted_content = stripped_line + + newline = "\n" if has_newline else "" + formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}") + + return "".join(formatted_lines) + + class _ColorizedLine(t.NamedTuple): + """Result of colorizing an example line.""" + + text: str + expect_value: bool + + def _colorize_example_line( + self, + content: str, + *, + theme: t.Any, + expect_value: bool, + ) -> _ColorizedLine: + """Colorize a single example command line. + + Parameters + ---------- + content : str + The line content to colorize. + theme : Any + Theme object with color attributes (prog, action, etc.). + expect_value : bool + Whether the previous token expects a value. + + Returns + ------- + _ColorizedLine + Named tuple with colorized text and updated expect_value state. + + Examples + -------- + With an empty theme (no colors), returns text unchanged: + + >>> formatter = TmuxpHelpFormatter("test") + >>> theme = HelpTheme.from_colors(None) + >>> result = formatter._colorize_example_line( + ... "tmuxp load", theme=theme, expect_value=False + ... ) + >>> result.text + 'tmuxp load' + >>> result.expect_value + False + """ + parts: list[str] = [] + expecting_value = expect_value + first_token = True + colored_subcommand = False + + for match in re.finditer(r"\s+|\S+", content): + token = match.group() + if token.isspace(): + parts.append(token) + continue + + if expecting_value: + color = theme.label + expecting_value = False + elif token.startswith("--"): + color = theme.long_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif token.startswith("-"): + color = theme.short_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif first_token: + color = theme.prog + elif not colored_subcommand: + color = theme.action + colored_subcommand = True + else: + color = None + + first_token = False + + if color: + parts.append(f"{color}{token}{theme.reset}") + else: + parts.append(token) + + return self._ColorizedLine(text="".join(parts), expect_value=expecting_value) + + +class HelpTheme(t.NamedTuple): + """Theme colors for help output. + + Examples + -------- + >>> from tmuxp.cli._formatter import HelpTheme + >>> theme = HelpTheme.from_colors(None) + >>> theme.reset + '' + """ + + prog: str + action: str + long_option: str + short_option: str + label: str + heading: str + reset: str + + @classmethod + def from_colors(cls, colors: t.Any) -> HelpTheme: + """Create theme from a :class:`~tmuxp._internal.colors.Colors` instance. + + Parameters + ---------- + colors : :class:`~tmuxp._internal.colors.Colors` | None + Colors instance, or None for no colors. + + Returns + ------- + HelpTheme + Theme with ANSI codes if colors enabled, empty strings otherwise. + + Examples + -------- + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> from tmuxp.cli._formatter import HelpTheme + >>> colors = Colors(ColorMode.NEVER) + >>> theme = HelpTheme.from_colors(colors) + >>> theme.reset + '' + """ + if colors is None or not colors._enabled: + return cls( + prog="", + action="", + long_option="", + short_option="", + label="", + heading="", + reset="", + ) + + # Import style here to avoid circular import + from tmuxp.cli._colors import style + + return cls( + prog=style("", fg="magenta", bold=True).removesuffix("\033[0m"), + action=style("", fg="cyan").removesuffix("\033[0m"), + long_option=style("", fg="green").removesuffix("\033[0m"), + short_option=style("", fg="green").removesuffix("\033[0m"), + label=style("", fg="yellow").removesuffix("\033[0m"), + heading=style("", fg="blue").removesuffix("\033[0m"), + reset="\033[0m", + ) + + +def create_themed_formatter( + colors: t.Any | None = None, +) -> type[TmuxpHelpFormatter]: + """Create a help formatter class with theme bound. + + This factory creates a formatter subclass with the theme injected, + allowing colorized help output without modifying argparse internals. + + When no colors argument is provided, uses AUTO mode which respects + NO_COLOR, FORCE_COLOR environment variables and TTY detection. + + Parameters + ---------- + colors : :class:`~tmuxp._internal.colors.Colors` | None + Colors instance for styling. If None, uses + :attr:`~tmuxp._internal.colors.ColorMode.AUTO`. + + Returns + ------- + type[TmuxpHelpFormatter] + Formatter class with theme bound. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> from tmuxp.cli._formatter import create_themed_formatter, HelpTheme + + With explicit colors enabled: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is not None + True + + With colors disabled: + + >>> colors = Colors(ColorMode.NEVER) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is None + True + """ + # Import here to avoid circular import at module load + from tmuxp.cli._colors import ColorMode, Colors + + if colors is None: + colors = Colors(ColorMode.AUTO) + + # Create theme if colors are enabled, None otherwise + theme = HelpTheme.from_colors(colors) if colors._enabled else None + + class ThemedTmuxpHelpFormatter(TmuxpHelpFormatter): + """TmuxpHelpFormatter with theme pre-configured.""" + + def __init__(self, prog: str, **kwargs: t.Any) -> None: + super().__init__(prog, **kwargs) + self._theme = theme + + return ThemedTmuxpHelpFormatter diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py new file mode 100644 index 0000000000..b62f1cc05c --- /dev/null +++ b/src/tmuxp/cli/_output.py @@ -0,0 +1,219 @@ +"""Output formatting utilities for tmuxp CLI. + +Provides structured output modes (JSON, NDJSON) alongside human-readable output. + +Examples +-------- +>>> from tmuxp.cli._output import OutputMode, OutputFormatter, get_output_mode + +Get output mode from flags: + +>>> get_output_mode(json_flag=False, ndjson_flag=False) + +>>> get_output_mode(json_flag=True, ndjson_flag=False) + +>>> get_output_mode(json_flag=False, ndjson_flag=True) + + +NDJSON takes precedence over JSON: + +>>> get_output_mode(json_flag=True, ndjson_flag=True) + +""" + +from __future__ import annotations + +import enum +import json +import logging +import sys +import typing as t + +logger = logging.getLogger(__name__) + + +class OutputMode(enum.Enum): + """Output format modes for CLI commands. + + Examples + -------- + >>> OutputMode.HUMAN.value + 'human' + >>> OutputMode.JSON.value + 'json' + >>> OutputMode.NDJSON.value + 'ndjson' + """ + + HUMAN = "human" + JSON = "json" + NDJSON = "ndjson" + + +class OutputFormatter: + """Manage output formatting for different modes (human, JSON, NDJSON). + + Parameters + ---------- + mode : OutputMode + The output mode to use (human, json, ndjson). Default is HUMAN. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.mode + + + >>> formatter = OutputFormatter() + >>> formatter.mode + + """ + + def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None: + """Initialize the output formatter.""" + self.mode = mode + self._json_buffer: list[dict[str, t.Any]] = [] + + def emit(self, data: dict[str, t.Any]) -> None: + """Emit a data event. + + In NDJSON mode, immediately writes one JSON object per line. + In JSON mode, buffers data for later output as a single array. + In HUMAN mode, does nothing (use emit_text for human output). + + Parameters + ---------- + data : dict + Event data to emit as JSON. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test", "path": "/tmp"}) + >>> len(formatter._json_buffer) + 1 + """ + if self.mode == OutputMode.NDJSON: + # Stream one JSON object per line immediately + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.JSON: + # Buffer for later output as single array + self._json_buffer.append(data) + # Human mode: handled by specific command implementations + + def emit_text(self, text: str) -> None: + """Emit human-readable text (only in HUMAN mode). + + Parameters + ---------- + text : str + Text to output. + + Examples + -------- + >>> import io + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_text("This won't print") # No output in JSON mode + """ + if self.mode == OutputMode.HUMAN: + sys.stdout.write(text + "\n") + sys.stdout.flush() + + def emit_object(self, data: dict[str, t.Any]) -> None: + """Emit a single top-level JSON object (not a list of records). + + For commands that produce one structured object rather than a stream of + records. Writes immediately without buffering; does not affect + ``_json_buffer``. + + In JSON mode, writes indented JSON followed by a newline. + In NDJSON mode, writes compact single-line JSON followed by a newline. + In HUMAN mode, does nothing (use ``emit_text`` for human output). + + Parameters + ---------- + data : dict + The object to emit. + + Examples + -------- + >>> import io, sys + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_object({"status": "ok", "count": 3}) + { + "status": "ok", + "count": 3 + } + >>> formatter._json_buffer # buffer is unaffected + [] + + >>> formatter2 = OutputFormatter(OutputMode.NDJSON) + >>> formatter2.emit_object({"status": "ok", "count": 3}) + {"status": "ok", "count": 3} + + >>> formatter3 = OutputFormatter(OutputMode.HUMAN) + >>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode + """ + if self.mode == OutputMode.JSON: + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.NDJSON: + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + # HUMAN: no-op + + def finalize(self) -> None: + """Finalize output (flush JSON buffer if needed). + + In JSON mode, outputs the buffered data as a formatted JSON array. + In other modes, does nothing. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test1"}) + >>> formatter.emit({"name": "test2"}) + >>> len(formatter._json_buffer) + 2 + >>> # formatter.finalize() would print the JSON array + """ + if self.mode == OutputMode.JSON and self._json_buffer: + sys.stdout.write(json.dumps(self._json_buffer, indent=2) + "\n") + sys.stdout.flush() + self._json_buffer.clear() + + +def get_output_mode(json_flag: bool, ndjson_flag: bool) -> OutputMode: + """Determine output mode from command flags. + + NDJSON takes precedence over JSON if both are specified. + + Parameters + ---------- + json_flag : bool + Whether --json was specified. + ndjson_flag : bool + Whether --ndjson was specified. + + Returns + ------- + OutputMode + The determined output mode. + + Examples + -------- + >>> get_output_mode(json_flag=False, ndjson_flag=False) + + >>> get_output_mode(json_flag=True, ndjson_flag=False) + + >>> get_output_mode(json_flag=False, ndjson_flag=True) + + >>> get_output_mode(json_flag=True, ndjson_flag=True) + + """ + if ndjson_flag: + return OutputMode.NDJSON + if json_flag: + return OutputMode.JSON + return OutputMode.HUMAN diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py new file mode 100644 index 0000000000..b2783f3ff7 --- /dev/null +++ b/src/tmuxp/cli/_progress.py @@ -0,0 +1,1131 @@ +"""Progress indicators for tmuxp CLI. + +This module provides a threaded spinner for long-running operations, +using only standard library and ANSI escape sequences. +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses +import itertools +import logging +import shutil +import sys +import threading +import time +import typing as t + +from tmuxp._internal.colors import ANSI_SEQ_RE, ColorMode, Colors, strip_ansi + +logger = logging.getLogger(__name__) + + +if t.TYPE_CHECKING: + import types + + +# ANSI Escape Sequences +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +ERASE_LINE = "\033[2K" +CURSOR_TO_COL0 = "\r" +CURSOR_UP_1 = "\x1b[1A" +SYNC_START = "\x1b[?2026h" # synchronized output: buffer until SYNC_END +SYNC_END = "\x1b[?2026l" # flush — prevents multi-line flicker + +# Spinner frames (braille pattern) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + +BAR_WIDTH = 10 # inner fill character count +DEFAULT_OUTPUT_LINES = 3 # default spinner panel height (lines of script output) + + +def _visible_len(s: str) -> int: + r"""Return visible length of *s*, ignoring ANSI escapes. + + Examples + -------- + >>> _visible_len("hello") + 5 + >>> _visible_len("\033[32mgreen\033[0m") + 5 + >>> _visible_len("") + 0 + """ + return len(strip_ansi(s)) + + +def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: + r"""Truncate *text* to *max_visible* visible characters, preserving ANSI sequences. + + If the visible length of *text* is already within *max_visible*, it is + returned unchanged. Otherwise the text is cut so that exactly + *max_visible* visible characters remain, a ``\x1b[0m`` reset is appended + (to prevent color bleed), followed by *suffix*. + + Parameters + ---------- + text : str + Input string, possibly containing ANSI escape sequences. + max_visible : int + Maximum number of visible (non-ANSI) characters to keep. + suffix : str + Appended after the reset when truncation occurs. Default ``"..."``. + + Returns + ------- + str + Truncated string with ANSI sequences intact. + + Examples + -------- + Plain text truncation: + + >>> _truncate_visible("hello world", 5) + 'hello\x1b[0m...' + + ANSI sequences are preserved whole: + + >>> _truncate_visible("\033[32mgreen\033[0m", 3) + '\x1b[32mgre\x1b[0m...' + + No truncation needed: + + >>> _truncate_visible("short", 10) + 'short' + + Empty string: + + >>> _truncate_visible("", 5) + '' + """ + if max_visible <= 0: + return "" + if _visible_len(text) <= max_visible: + return text + + result: list[str] = [] + visible = 0 + i = 0 + while i < len(text) and visible < max_visible: + m = ANSI_SEQ_RE.match(text, i) + if m: + result.append(m.group()) + i = m.end() + else: + result.append(text[i]) + visible += 1 + i += 1 + return "".join(result) + "\x1b[0m" + suffix + + +SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" + +PROGRESS_PRESETS: dict[str, str] = { + "default": "Loading workspace: {session} {bar} {progress} {window}", + "minimal": "Loading workspace: {session} [{window_progress}]", + "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", + "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", + "verbose": ( + "Loading workspace: {session} [window {window_index} of {window_total}" + " · pane {pane_index} of {pane_total}] {window}" + ), +} + + +def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: + """Render a plain-text ASCII progress bar without color. + + Parameters + ---------- + done : int + Completed units. + total : int + Total units. When ``<= 0``, returns ``""``. + width : int + Inner fill character count; default :data:`BAR_WIDTH`. + + Returns + ------- + str + A bar like ``"█████░░░░░"``. + Returns ``""`` when *total* <= 0 or *width* <= 0. + + Examples + -------- + >>> render_bar(0, 10) + '░░░░░░░░░░' + >>> render_bar(5, 10) + '█████░░░░░' + >>> render_bar(10, 10) + '██████████' + >>> render_bar(0, 0) + '' + >>> render_bar(3, 10, width=5) + '█░░░░' + """ + if total <= 0 or width <= 0: + return "" + filled = min(width, int(done / total * width)) + return "█" * filled + "░" * (width - filled) + + +class _SafeFormatMap(dict): # type: ignore[type-arg] + """dict subclass that returns ``{key}`` for missing keys in format_map.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def resolve_progress_format(fmt: str) -> str: + """Return the format string for *fmt*, resolving preset names. + + If *fmt* is a key in :data:`PROGRESS_PRESETS` the corresponding + format string is returned; otherwise *fmt* is returned as-is. + + Examples + -------- + >>> resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + True + >>> resolve_progress_format("{session} w{window_progress}") + '{session} w{window_progress}' + >>> resolve_progress_format("unknown-preset") + 'unknown-preset' + """ + return PROGRESS_PRESETS.get(fmt, fmt) + + +@dataclasses.dataclass +class _WindowStatus: + """State for a single window in the build tree.""" + + name: str + done: bool = False + pane_num: int | None = None + pane_total: int | None = None + pane_done: int = 0 # panes completed in this window (set on window_done) + + +class BuildTree: + """Tracks session/window/pane build state; renders a structural progress tree. + + **Template Token Lifecycle** + + Each token is first available at the event listed in its column. + ``—`` means the value does not change at that phase. + + .. list-table:: + :header-rows: 1 + + * - Token + - Pre-``session_created`` + - After ``session_created`` + - After ``window_started`` + - After ``pane_creating`` + - After ``window_done`` + * - ``{session}`` + - ``""`` + - session name + - — + - — + - — + * - ``{window}`` + - ``""`` + - ``""`` + - window name + - — + - last window name + * - ``{window_index}`` + - ``0`` + - ``0`` + - N (1-based started count) + - — + - — + * - ``{window_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{window_progress}`` + - ``""`` + - ``""`` + - ``"N/M"`` when > 0 + - — + - — + * - ``{windows_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - increments + * - ``{windows_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{window_progress_rel}`` + - ``""`` + - ``"0/M"`` + - ``"0/M"`` + - — + - ``"N/M"`` + * - ``{pane_index}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - ``0`` + * - ``{pane_total}`` + - ``0`` + - ``0`` + - window's pane total + - — + - window's pane total + * - ``{pane_progress}`` + - ``""`` + - ``""`` + - ``""`` + - ``"N/M"`` + - ``""`` + * - ``{pane_done}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - pane_total + * - ``{pane_remaining}`` + - ``0`` + - ``0`` + - pane_total + - decrements + - ``0`` + * - ``{pane_progress_rel}`` + - ``""`` + - ``""`` + - ``"0/M"`` + - ``"N/M"`` + - ``"M/M"`` + * - ``{progress}`` + - ``""`` + - ``""`` + - ``"N/M win"`` + - ``"N/M win · P/Q pane"`` + - — + * - ``{session_pane_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{session_panes_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - accumulated + * - ``{session_panes_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{session_pane_progress}`` + - ``""`` + - ``"0/T"`` + - — + - — + - ``"N/T"`` + * - ``{overall_percent}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - updates + * - ``{summary}`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``"[N win, M panes]"`` + * - ``{bar}`` (spinner) + - ``[░░…]`` + - ``[░░…]`` + - starts filling + - fractional + - jumps + * - ``{pane_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{window_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{status_icon}`` (spinner) + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + + During ``before_script``: ``{bar}``, ``{pane_bar}``, ``{window_bar}`` show a + marching animation; ``{status_icon}`` = ``⏸``. + + Examples + -------- + Empty tree renders nothing: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> tree = BuildTree() + >>> tree.render(colors, 80) + [] + + After session_created event the header appears: + + >>> tree.on_event({"event": "session_created", "name": "my-session"}) + >>> tree.render(colors, 80) + ['Session'] + + After window_started and pane_creating: + + >>> tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + >>> tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- editor, pane (1 of 2)' + + After window_done the window gets a checkmark: + + >>> tree.on_event({"event": "window_done"}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- ✓ editor' + + **Inline status format:** + + >>> tree2 = BuildTree() + >>> tree2.format_inline("Building projects...") + 'Building projects...' + >>> tree2.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai' + >>> tree2.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + >>> tree2.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs' + """ + + def __init__(self, workspace_path: str = "") -> None: + self.workspace_path: str = workspace_path + self.session_name: str | None = None + self.windows: list[_WindowStatus] = [] + self.window_total: int | None = None + self.session_pane_total: int | None = None + self.session_panes_done: int = 0 + self.windows_done: int = 0 + self._before_script_event: threading.Event = threading.Event() + + def on_event(self, event: dict[str, t.Any]) -> None: + """Update tree state from a build event dict. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({ + ... "event": "session_created", "name": "dev", "window_total": 2, + ... }) + >>> tree.session_name + 'dev' + >>> tree.window_total + 2 + >>> tree.on_event({ + ... "event": "window_started", "name": "editor", "pane_total": 3, + ... }) + >>> len(tree.windows) + 1 + >>> tree.windows[0].name + 'editor' + """ + kind = event["event"] + if kind == "session_created": + self.session_name = event["name"] + self.window_total = event.get("window_total") + self.session_pane_total = event.get("session_pane_total") + elif kind == "before_script_started": + self._before_script_event.set() + elif kind == "before_script_done": + self._before_script_event.clear() + elif kind == "window_started": + self.windows.append( + _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + ) + elif kind == "pane_creating": + if self.windows: + w = self.windows[-1] + w.pane_num = event["pane_num"] + w.pane_total = event["pane_total"] + elif kind == "window_done": + if self.windows: + w = self.windows[-1] + w.done = True + w.pane_num = None + w.pane_done = w.pane_total or 0 + self.session_panes_done += w.pane_done + self.windows_done += 1 + elif kind == "workspace_built": + for w in self.windows: + w.done = True + + def render(self, colors: Colors, width: int) -> list[str]: + """Render the current tree state to a list of display strings. + + Parameters + ---------- + colors : :class:`~tmuxp._internal.colors.Colors` + Colors instance for ANSI styling. + width : int + Terminal width; window lines are truncated to ``width - 1``. + + Returns + ------- + list[str] + Lines to display; empty list if no session has been created yet. + """ + if self.session_name is None: + return [] + lines: list[str] = [colors.heading("Session")] + for w in self.windows: + if w.done: + line = f"- {colors.success('✓')} {colors.highlight(w.name)}" + elif w.pane_num is not None and w.pane_total is not None: + line = ( + f"- {colors.highlight(w.name)}" + f"{colors.muted(f', pane ({w.pane_num} of {w.pane_total})')}" + ) + else: + line = f"- {colors.highlight(w.name)}" + lines.append(_truncate_visible(line, width - 1, suffix="")) + return lines + + def _context(self) -> dict[str, t.Any]: + """Return the current build-state token dict for template rendering. + + Examples + -------- + Zero-state before any events: + + >>> tree = BuildTree(workspace_path="~/.tmuxp/myapp.yaml") + >>> ev = { + ... "event": "session_created", + ... "name": "myapp", + ... "window_total": 5, + ... "session_pane_total": 10, + ... } + >>> tree.on_event(ev) + >>> ctx = tree._context() + >>> ctx["workspace_path"] + '~/.tmuxp/myapp.yaml' + >>> ctx["session"] + 'myapp' + >>> ctx["window_total"] + 5 + >>> ctx["window_index"] + 0 + >>> ctx["progress"] + '' + >>> ctx["windows_done"] + 0 + >>> ctx["windows_remaining"] + 5 + >>> ctx["window_progress_rel"] + '0/5' + >>> ctx["session_pane_total"] + 10 + >>> ctx["session_panes_remaining"] + 10 + >>> ctx["session_pane_progress"] + '0/10' + >>> ctx["summary"] + '' + + After windows complete, summary shows counts: + + >>> tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + >>> tree.on_event({"event": "window_done"}) + >>> tree.on_event({"event": "window_started", "name": "w2", "pane_total": 5}) + >>> tree.on_event({"event": "window_done"}) + >>> tree._context()["summary"] + '[2 win, 8 panes]' + """ + w = self.windows[-1] if self.windows else None + window_idx = len(self.windows) + win_tot = self.window_total or 0 + pane_idx = (w.pane_num or 0) if w else 0 + pane_tot = (w.pane_total or 0) if w else 0 + + win_progress = f"{window_idx}/{win_tot}" if win_tot and window_idx > 0 else "" + pane_progress = f"{pane_idx}/{pane_tot}" if pane_tot and pane_idx > 0 else "" + progress_parts = [ + f"{win_progress} win" if win_progress else "", + f"{pane_progress} pane" if pane_progress else "", + ] + progress = " · ".join(p for p in progress_parts if p) + + win_done = self.windows_done + win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" + + pane_done_cur = ( + (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) + ) + pane_remaining = max(0, pane_tot - pane_done_cur) + pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" + + spt = self.session_pane_total or 0 + session_pane_progress = f"{self.session_panes_done}/{spt}" if spt else "" + overall_percent = int(self.session_panes_done / spt * 100) if spt else 0 + + summary_parts: list[str] = [] + if self.windows_done: + summary_parts.append(f"{self.windows_done} win") + if self.session_panes_done: + summary_parts.append(f"{self.session_panes_done} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + + return { + "workspace_path": self.workspace_path, + "session": self.session_name or "", + "window": w.name if w else "", + "window_index": window_idx, + "window_total": win_tot, + "window_progress": win_progress, + "pane_index": pane_idx, + "pane_total": pane_tot, + "pane_progress": pane_progress, + "progress": progress, + "windows_done": win_done, + "windows_remaining": max(0, win_tot - win_done), + "window_progress_rel": win_progress_rel, + "pane_done": pane_done_cur, + "pane_remaining": pane_remaining, + "pane_progress_rel": pane_progress_rel, + "session_pane_total": spt, + "session_panes_done": self.session_panes_done, + "session_panes_remaining": max(0, spt - self.session_panes_done), + "session_pane_progress": session_pane_progress, + "overall_percent": overall_percent, + "summary": summary, + } + + def format_template( + self, + fmt: str, + extra: dict[str, t.Any] | None = None, + ) -> str: + """Render *fmt* with the current build state. + + Returns ``""`` before ``session_created`` fires so callers can + fall back to a pre-build message. Unknown ``{tokens}`` are left + as-is (not dropped silently). + + The optional *extra* dict is merged on top of :meth:`_context` so + callers (e.g. :class:`Spinner`) can inject ANSI-colored tokens like + ``{bar}`` without adding color concerns to :class:`BuildTree`. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.format_template("{session} [{progress}] {window}") + '' + >>> ev = {"event": "session_created", "name": "cihai", "window_total": 3} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [] ' + >>> ev = {"event": "window_started", "name": "editor", "pane_total": 4} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win] editor' + >>> tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win · 2/4 pane] editor' + >>> tree.format_template("minimal: {session} [{window_progress}]") + 'minimal: cihai [1/3]' + >>> tree.format_template("{session} {unknown_token}") + 'cihai {unknown_token}' + >>> tree.format_template("{session}", extra={"custom": "value"}) + 'cihai' + """ + if self.session_name is None: + return "" + ctx: dict[str, t.Any] = self._context() + if extra: + ctx = {**ctx, **extra} + return fmt.format_map(_SafeFormatMap(ctx)) + + def format_inline(self, base: str) -> str: + """Return base message with current build state appended inline. + + Parameters + ---------- + base : str + The original spinner message to start from. + + Returns + ------- + str + ``base`` alone if no session has been created yet; otherwise + ``"base session_name [W of N windows, P of M panes] window_name"``, + omitting the bracket section when there is no current window, and + omitting individual parts when their totals are not known. + """ + if self.session_name is None: + return base + parts = [base, self.session_name] + if self.windows: + w = self.windows[-1] + window_idx = len(self.windows) + bracket_parts: list[str] = [] + if self.window_total is not None: + bracket_parts.append(f"{window_idx} of {self.window_total} windows") + if w.pane_num is not None and w.pane_total is not None: + bracket_parts.append(f"{w.pane_num} of {w.pane_total} panes") + if bracket_parts: + parts.append(f"[{', '.join(bracket_parts)}]") + parts.append(w.name) + return " ".join(parts) + + +class Spinner: + """A threaded spinner for CLI progress. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> with Spinner("Build...", color_mode=ColorMode.NEVER, stream=stream) as spinner: + ... spinner.add_output_line("Session created: test") + ... spinner.update_message("Creating window: editor") + """ + + def __init__( + self, + message: str = "Loading...", + color_mode: ColorMode = ColorMode.AUTO, + stream: t.TextIO = sys.stderr, + interval: float = 0.1, + output_lines: int = DEFAULT_OUTPUT_LINES, + progress_format: str | None = None, + workspace_path: str = "", + ) -> None: + """Initialize spinner. + + Parameters + ---------- + message : str + Text displayed next to the spinner animation. + color_mode : :class:`~tmuxp._internal.colors.ColorMode` + ANSI color mode for styled output. + stream : t.TextIO + Output stream (default ``sys.stderr``). + interval : float + Seconds between animation frames. + output_lines : int + Max lines in the scrolling output panel. ``0`` hides the panel, + ``-1`` means unlimited. + progress_format : str | None + Format string for progress output. Tokens are documented in + :class:`BuildTree`. ``None`` uses the built-in default. + workspace_path : str + Absolute path to the workspace config file, shown in success + output. + """ + self.message = message + self._base_message = message + self.colors = Colors(color_mode) + self.stream = stream + self.interval = interval + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._enabled = self._should_enable() + self._panel_hidden = output_lines == 0 + if output_lines < 0: + self._output_lines: collections.deque[str] = ( + collections.deque() + ) # unlimited + elif output_lines == 0: + self._output_lines = collections.deque(maxlen=1) # drop, never render + else: + self._output_lines = collections.deque(maxlen=output_lines) + self._prev_height: int = 0 + self._build_tree: BuildTree = BuildTree(workspace_path=workspace_path) + self._progress_format: str | None = ( + resolve_progress_format(progress_format) + if progress_format is not None + else None + ) + + def _should_enable(self) -> bool: + """Check if spinner should be enabled (TTY check).""" + return self.stream.isatty() + + def _restore_cursor(self) -> None: + """Unconditionally restore cursor — called by atexit on abnormal exit.""" + self.stream.write(SHOW_CURSOR) + self.stream.flush() + + def _spin(self) -> None: + """Spin in background thread.""" + frames = itertools.cycle(SPINNER_FRAMES) + march_pos = 0 # marching bar position counter (local to _spin) + + self.stream.write(HIDE_CURSOR) + self.stream.flush() + + try: + while not self._stop_event.is_set(): + frame = next(frames) + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if self._panel_hidden: + panel: list[str] = [] + else: + term_height = shutil.get_terminal_size( + fallback=(80, 24), + ).lines + raw_panel = list(self._output_lines) + max_panel = term_height - 2 + if len(raw_panel) > max_panel: + raw_panel = raw_panel[-max_panel:] + panel = [ + _truncate_visible(line, term_width - 1, suffix="") + for line in raw_panel + ] + new_height = len(panel) + 1 # panel lines + spinner line + + parts: list[str] = [] + + # Erase previous render (cursor is at end of previous spinner line) + if self._prev_height > 0: + parts.append(f"{CURSOR_TO_COL0}{ERASE_LINE}") + parts.extend( + f"{CURSOR_UP_1}{ERASE_LINE}" + for _ in range(self._prev_height - 1) + ) + + # Write panel lines (tree lines already constrained by render()) + parts.extend(f"{output_line}\n" for output_line in panel) + + # Determine final spinner message + if ( + self._progress_format is not None + and self._build_tree._before_script_event.is_set() + ): + # Marching bar: sweep a 2-cell highlight across the bar + p = march_pos % max(1, BAR_WIDTH - 1) + march_bar = ( + self.colors.muted("░" * p) + + self.colors.warning("░░") + + self.colors.muted("░" * max(0, BAR_WIDTH - p - 2)) + ) + tree = self._build_tree + extra: dict[str, t.Any] = { + "session": self.colors.highlight( + tree.session_name or "", + ), + "bar": march_bar, + "pane_bar": march_bar, + "window_bar": march_bar, + "status_icon": self.colors.warning("⏸"), + } + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + msg = rendered if rendered else self._base_message + march_pos += 1 + else: + msg = self.message + march_pos = 0 # reset when not in before_script + + # Write spinner line (no trailing newline — cursor stays here) + spinner_text = f"{self.colors.info(frame)} {msg}" + if _visible_len(spinner_text) > term_width - 1: + spinner_text = _truncate_visible(spinner_text, term_width - 4) + parts.append(f"{CURSOR_TO_COL0}{spinner_text}") + + # Wrap entire frame in synchronized output to prevent flicker. + # Terminals that don't support it safely ignore the sequences. + self.stream.write(SYNC_START + "".join(parts) + SYNC_END) + self.stream.flush() + self._prev_height = new_height + time.sleep(self.interval) + finally: + # Erase the whole block and show cursor + if self._prev_height > 0: + self.stream.write(f"{CURSOR_TO_COL0}{ERASE_LINE}") + for _ in range(self._prev_height - 1): + self.stream.write(f"{CURSOR_UP_1}{ERASE_LINE}") + self.stream.write(SHOW_CURSOR) + self.stream.flush() + self._prev_height = 0 + + def add_output_line(self, line: str) -> None: + r"""Append a line to the live output panel (thread-safe via GIL). + + When the spinner is disabled (non-TTY), writes directly to the stream + so output is not silently swallowed. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.add_output_line("hello world") + >>> stream.getvalue() + 'hello world\n' + """ + stripped = line.rstrip("\n\r") + if stripped: + if self._enabled: + self._output_lines.append(stripped) + else: + self.stream.write(stripped + "\n") + self.stream.flush() + + def update_message(self, message: str) -> None: + """Update the message displayed next to the spinner. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("initial", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.message + 'initial' + >>> spinner.update_message("updated") + >>> spinner.message + 'updated' + """ + self.message = message + + def _build_extra(self) -> dict[str, t.Any]: + """Return spinner-owned template tokens (colored bar, status_icon). + + These are separated from :meth:`BuildTree._context` to keep ANSI/color + concerns out of :class:`BuildTree`, which is also used in tests without + colors. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner._build_tree.on_event( + ... { + ... "event": "session_created", + ... "name": "s", + ... "window_total": 4, + ... "session_pane_total": 8, + ... } + ... ) + >>> extra = spinner._build_extra() + >>> extra["bar"] + '░░░░░░░░░░' + >>> extra["status_icon"] + '' + """ + tree = self._build_tree + win_tot = tree.window_total or 0 + spt = tree.session_pane_total or 0 + + # Composite fraction: (windows_done + pane_frac) / window_total + if win_tot > 0: + cw = tree.windows[-1] if tree.windows else None + pane_frac = 0.0 + if cw and not cw.done and cw.pane_total: + pane_frac = (cw.pane_num or 0) / cw.pane_total + composite_done = tree.windows_done + pane_frac + composite_bar = render_bar(int(composite_done * 100), win_tot * 100) + else: + composite_bar = render_bar(0, 0) + + pane_bar = render_bar(tree.session_panes_done, spt) + window_bar = render_bar(tree.windows_done, win_tot) + + def _color_bar(plain: str) -> str: + if not plain: + return plain + filled = plain.count("█") + empty = plain.count("░") + return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + + return { + "session": self.colors.highlight(tree.session_name or ""), + "bar": _color_bar(composite_bar), + "pane_bar": _color_bar(pane_bar), + "window_bar": _color_bar(window_bar), + "status_icon": "", + } + + def on_build_event(self, event: dict[str, t.Any]) -> None: + """Forward build event to BuildTree and update spinner message inline. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("Loading", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.on_build_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 3, + ... }) + >>> spinner._build_tree.session_name + 'myapp' + """ + self._build_tree.on_event(event) + if self._progress_format is not None: + extra = self._build_extra() + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + # Only switch to template output once a window has started so that + # the session_created → window_started gap doesn't show empty brackets. + self.message = ( + rendered + if (rendered and self._build_tree.windows) + else self._base_message + ) + else: + self.message = self._build_tree.format_inline(self._base_message) + + def start(self) -> None: + """Start the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + """ + if not self._enabled: + return + + atexit.register(self._restore_cursor) + self._stop_event.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + >>> spinner._thread is None + True + """ + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join() + self._thread = None + atexit.unregister(self._restore_cursor) + + def format_success(self) -> str: + """Render the success template with current build state. + + Uses :data:`SUCCESS_TEMPLATE` with colored ``{session}`` + (``highlight()``), ``{workspace_path}`` (``info()``), and + ``{summary}`` (``muted()``) from :meth:`BuildTree._context`. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream, + ... workspace_path="~/.tmuxp/myapp.yaml") + >>> spinner._build_tree.on_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 4, + ... }) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w1", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w2", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner.format_success() + 'Loaded workspace: myapp (~/.tmuxp/myapp.yaml) [2 win, 4 panes]' + """ + tree = self._build_tree + ctx = tree._context() + extra: dict[str, t.Any] = { + "session": self.colors.highlight(tree.session_name or ""), + "workspace_path": self.colors.info(ctx.get("workspace_path", "")), + "summary": self.colors.muted(ctx.get("summary", "")) + if ctx.get("summary") + else "", + } + return SUCCESS_TEMPLATE.format_map(_SafeFormatMap({**ctx, **extra})) + + def success(self, text: str | None = None) -> None: + """Stop the spinner and print a success line. + + Parameters + ---------- + text : str | None + The success message to display after the checkmark. + When ``None``, uses :meth:`format_success` if a progress format + is configured, otherwise falls back to ``_base_message``. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.success("done") + >>> "✓ done" in stream.getvalue() + True + + With no args and no progress format, falls back to base message: + + >>> stream2 = io.StringIO() + >>> spinner2 = Spinner("Loading...", color_mode=ColorMode.NEVER, stream=stream2) + >>> spinner2.success() + >>> "✓ Loading..." in stream2.getvalue() + True + """ + self.stop() + if text is None and self._progress_format is not None: + text = self.format_success() + elif text is None: + text = self._base_message + checkmark = self.colors.success("\u2713") + msg = f"{checkmark} {text}" + self.stream.write(f"{msg}\n") + self.stream.flush() + + def __enter__(self) -> Spinner: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> t.Literal[False]: + self.stop() + return False diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py new file mode 100644 index 0000000000..a92cfebbfa --- /dev/null +++ b/src/tmuxp/cli/convert.py @@ -0,0 +1,140 @@ +"""CLI for ``tmuxp convert`` subcommand.""" + +from __future__ import annotations + +import locale +import logging +import os +import pathlib +import typing as t + +from tmuxp import exc +from tmuxp._internal.config_reader import ConfigReader +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 prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +CONVERT_DESCRIPTION = build_description( + """ + Convert workspace files between YAML and JSON format. + """, + ( + ( + None, + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + "tmuxp convert -y workspace.yaml", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + AllowedFileTypes = t.Literal["json", "yaml"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_convert_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``convert`` subcommand.""" + workspace_file = parser.add_argument( + dest="workspace_file", + type=str, + metavar="workspace-file", + help="checks tmuxp and current directory for workspace files.", + ) + try: + import shtab + + workspace_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + return parser + + +class ConvertUnknownFileType(exc.TmuxpException): + """Raise if tmuxp convert encounters an unknown filetype.""" + + def __init__(self, ext: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])", + ) + + +def command_convert( + workspace_file: str | pathlib.Path, + answer_yes: bool, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp convert`` convert a tmuxp config between JSON and YAML.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + workspace_file = find_workspace_file( + workspace_file, + workspace_dir=get_workspace_dir(), + ) + + if isinstance(workspace_file, str): + workspace_file = pathlib.Path(workspace_file) + + _, ext = os.path.splitext(workspace_file) + ext = ext.lower() + to_filetype: AllowedFileTypes + if ext == ".json": + to_filetype = "yaml" + elif ext in {".yaml", ".yml"}: + to_filetype = "json" + else: + raise ConvertUnknownFileType(ext) + + configparser = ConfigReader.from_file(workspace_file) + newfile = workspace_file.parent / (str(workspace_file.stem) + f".{to_filetype}") + + new_workspace = configparser.dump( + fmt=to_filetype, + indent=2, + **{"default_flow_style": False} if to_filetype == "yaml" else {}, + ) + + if ( + not answer_yes + and prompt_yes_no( + f"Convert {colors.info(str(PrivatePath(workspace_file)))} to " + f"{colors.highlight(to_filetype)}?", + color_mode=color_mode, + ) + and prompt_yes_no( + f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?", + color_mode=color_mode, + ) + ): + answer_yes = True + + if answer_yes: + pathlib.Path(newfile).write_text( + new_workspace, + encoding=locale.getpreferredencoding(False), + ) + tmuxp_echo( + colors.success("New workspace file saved to ") + + colors.info(str(PrivatePath(newfile))) + + ".", + ) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py new file mode 100644 index 0000000000..4e1bf0c5d5 --- /dev/null +++ b/src/tmuxp/cli/debug_info.py @@ -0,0 +1,265 @@ +"""CLI for ``tmuxp debug-info`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import platform +import shutil +import sys +import typing as t + +from libtmux.__about__ import __version__ as libtmux_version +from libtmux.common import get_version_str, tmux_cmd + +from tmuxp.__about__ import __version__ +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +DEBUG_INFO_DESCRIPTION = build_description( + """ + Print diagnostic information for debugging and issue reports. + """, + ( + ( + None, + [ + "tmuxp debug-info", + ], + ), + ( + "Machine-readable output examples", + [ + "tmuxp debug-info --json", + ], + ), + ), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + +tmuxp_path = pathlib.Path(__file__).parent.parent + + +class CLIDebugInfoNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp debug-info command.""" + + color: CLIColorModeLiteral + output_json: bool + + +def create_debug_info_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``debug-info`` subcommand.""" + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + return parser + + +def _private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~. + + Parameters + ---------- + path : pathlib.Path | str | None + Path to mask. + + Returns + ------- + str + Path with home directory replaced by ~. + + Examples + -------- + >>> _private(None) + '' + >>> _private('') + '' + >>> _private('/usr/bin/tmux') + '/usr/bin/tmux' + """ + if path is None or path == "": + return "" + return str(PrivatePath(path)) + + +def _collect_debug_info() -> dict[str, t.Any]: + """Collect debug information as a structured dictionary. + + All paths are privacy-masked using PrivatePath (home → ~). + + Returns + ------- + dict[str, Any] + Debug information with environment, versions, paths, and tmux state. + + Examples + -------- + >>> data = _collect_debug_info() + >>> 'environment' in data + True + >>> 'tmux_version' in data + True + """ + # Collect tmux command outputs + sessions_resp = tmux_cmd("list-sessions") + windows_resp = tmux_cmd("list-windows") + panes_resp = tmux_cmd("list-panes") + global_opts_resp = tmux_cmd("show-options", "-g") + window_opts_resp = tmux_cmd("show-window-options", "-g") + + return { + "environment": { + "dist": platform.platform(), + "arch": platform.machine(), + "uname": list(platform.uname()[:3]), + "version": platform.version(), + }, + "python_version": " ".join(sys.version.split("\n")), + "system_path": collapse_home_in_string(os.environ.get("PATH", "")), + "tmux_version": get_version_str(), + "libtmux_version": libtmux_version, + "tmuxp_version": __version__, + "tmux_path": _private(shutil.which("tmux")), + "tmuxp_path": _private(tmuxp_path), + "shell": _private(os.environ.get("SHELL", "")), + "tmux": { + "sessions": sessions_resp.stdout, + "windows": windows_resp.stdout, + "panes": panes_resp.stdout, + "global_options": global_opts_resp.stdout, + "window_options": window_opts_resp.stdout, + }, + } + + +def _format_human_output(data: dict[str, t.Any], colors: Colors) -> str: + """Format debug info as human-readable colored output. + + Parameters + ---------- + data : dict[str, Any] + Debug information dictionary. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager for formatting. + + Returns + ------- + str + Formatted human-readable output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> data = { + ... "environment": { + ... "dist": "Linux", + ... "arch": "x86_64", + ... "uname": ["Linux", "host", "6.0"], + ... "version": "#1 SMP", + ... }, + ... "python_version": "3.12.0", + ... "system_path": "/usr/bin", + ... "tmux_version": "3.4", + ... "libtmux_version": "0.40.0", + ... "tmuxp_version": "1.50.0", + ... "tmux_path": "/usr/bin/tmux", + ... "tmuxp_path": "~/tmuxp", + ... "shell": "/bin/bash", + ... "tmux": { + ... "sessions": [], + ... "windows": [], + ... "panes": [], + ... "global_options": [], + ... "window_options": [], + ... }, + ... } + >>> output = _format_human_output(data, colors) + >>> "environment" in output + True + """ + + def format_tmux_section(lines: list[str]) -> str: + """Format tmux command output with syntax highlighting.""" + formatted_lines = [] + for line in lines: + formatted = colors.format_tmux_option(line) + formatted_lines.append(f"\t{formatted}") + return "\n".join(formatted_lines) + + env = data["environment"] + env_items = [ + f"\t{colors.format_kv('dist', env['dist'])}", + f"\t{colors.format_kv('arch', env['arch'])}", + f"\t{colors.format_kv('uname', '; '.join(env['uname']))}", + f"\t{colors.format_kv('version', env['version'])}", + ] + + tmux_data = data["tmux"] + output = [ + colors.format_separator(), + f"{colors.format_label('environment')}:\n" + "\n".join(env_items), + colors.format_separator(), + colors.format_kv("python version", data["python_version"]), + colors.format_kv("system PATH", data["system_path"]), + colors.format_kv("tmux version", colors.format_version(data["tmux_version"])), + colors.format_kv( + "libtmux version", colors.format_version(data["libtmux_version"]) + ), + colors.format_kv("tmuxp version", colors.format_version(data["tmuxp_version"])), + colors.format_kv("tmux path", colors.format_path(data["tmux_path"])), + colors.format_kv("tmuxp path", colors.format_path(data["tmuxp_path"])), + colors.format_kv("shell", data["shell"]), + colors.format_separator(), + f"{colors.format_label('tmux sessions')}:\n" + + format_tmux_section(tmux_data["sessions"]), + f"{colors.format_label('tmux windows')}:\n" + + format_tmux_section(tmux_data["windows"]), + f"{colors.format_label('tmux panes')}:\n" + + format_tmux_section(tmux_data["panes"]), + f"{colors.format_label('tmux global options')}:\n" + + format_tmux_section(tmux_data["global_options"]), + f"{colors.format_label('tmux window options')}:\n" + + format_tmux_section(tmux_data["window_options"]), + ] + + return "\n".join(output) + + +def command_debug_info( + args: CLIDebugInfoNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" + # Get output mode + output_json = args.output_json if args else False + + # Get color mode (only used for human output) + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Collect debug info + data = _collect_debug_info() + + # Output based on mode + if output_json: + # Single object, not wrapped in array + OutputFormatter(OutputMode.JSON).emit_object(data) + else: + tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py new file mode 100644 index 0000000000..7308f2eba7 --- /dev/null +++ b/src/tmuxp/cli/edit.py @@ -0,0 +1,73 @@ +"""CLI for ``tmuxp edit`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import subprocess +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +EDIT_DESCRIPTION = build_description( + """ + Open tmuxp workspace file in your system editor ($EDITOR). + """, + ( + ( + None, + [ + "tmuxp edit myproject", + "tmuxp edit ./workspace.yaml", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + import pathlib + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +def create_edit_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``edit`` subcommand.""" + parser.add_argument( + dest="workspace_file", + metavar="workspace-file", + type=str, + help="checks current tmuxp and current directory for workspace files.", + ) + return parser + + +def command_edit( + workspace_file: str | pathlib.Path, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp edit``, open tmuxp workspace file in system editor.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + workspace_file = find_workspace_file(workspace_file) + + sys_editor = os.environ.get("EDITOR", "vim") + tmuxp_echo( + colors.muted("Opening ") + + colors.info(str(PrivatePath(workspace_file))) + + colors.muted(" in ") + + colors.highlight(sys_editor, bold=False) + + colors.muted("..."), + ) + subprocess.call([sys_editor, workspace_file]) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py new file mode 100644 index 0000000000..fa26569ca7 --- /dev/null +++ b/src/tmuxp/cli/freeze.py @@ -0,0 +1,263 @@ +"""CLI for ``tmuxp freeze`` subcommand.""" + +from __future__ import annotations + +import argparse +import locale +import logging +import os +import pathlib +import sys +import typing as t + +from libtmux.server import Server + +from tmuxp import exc, util +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.exc import TmuxpException +from tmuxp.workspace import freezer +from tmuxp.workspace.finders import get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +FREEZE_DESCRIPTION = build_description( + """ + Freeze a live tmux session to a tmuxp workspace file. + """, + ( + ( + None, + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + "tmuxp freeze -f json mysession", + "tmuxp freeze -y mysession", + ], + ), + ), +) + +if t.TYPE_CHECKING: + from typing import TypeGuard + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + CLIOutputFormatLiteral: t.TypeAlias = t.Literal["yaml", "json"] + + +class CLIFreezeNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp freeze command.""" + + color: CLIColorModeLiteral + session_name: str + socket_name: str | None + socket_path: str | None + workspace_format: CLIOutputFormatLiteral | None + save_to: str | None + answer_yes: bool | None + quiet: bool | None + force: bool | None + + +def create_freeze_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``freeze`` subcommand.""" + parser.add_argument( + dest="session_name", + metavar="session-name", + nargs="?", + action="store", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + parser.add_argument( + "-f", + "--workspace-format", + choices=["yaml", "json"], + help="format to save in", + ) + parser.add_argument( + "-o", + "--save-to", + metavar="output-path", + type=pathlib.Path, + help="file to save to", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "--quiet", + "-q", + dest="quiet", + action="store_true", + help="don't prompt for confirmation", + ) + parser.add_argument( + "--force", + dest="force", + action="store_true", + help="overwrite the workspace file", + ) + + return parser + + +def command_freeze( + args: CLIFreezeNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp freeze``, snapshot a tmux session into a tmuxp workspace. + + If SESSION_NAME is provided, snapshot that session. Otherwise, use the current + session. + """ + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) + + try: + if args.session_name: + session = server.sessions.get(session_name=args.session_name, default=None) + else: + session = util.get_session(server) + + if not session: + raise exc.SessionNotFound + except TmuxpException as e: + tmuxp_echo(colors.error(str(e))) + return + + frozen_workspace = freezer.freeze(session) + workspace = freezer.inline(frozen_workspace) + configparser = ConfigReader(workspace) + + if not args.quiet: + tmuxp_echo( + colors.format_separator(63) + + "\n" + + colors.muted("Freeze does its best to snapshot live tmux sessions.") + + "\n", + ) + if not ( + args.answer_yes + or prompt_yes_no( + "The new workspace will require adjusting afterwards. Save workspace file?", + color_mode=color_mode, + ) + ): + if not args.quiet: + tmuxp_echo( + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info("") + + ".", + ) + sys.exit() + + dest = args.save_to + while not dest: + save_to = os.path.abspath( + os.path.join( + get_workspace_dir(), + "{}.{}".format( + frozen_workspace.get("session_name"), + args.workspace_format or "yaml", + ), + ), + ) + dest_prompt = prompt( + f"Save to: {PrivatePath(save_to)}", + default=save_to, + color_mode=color_mode, + ) + if not args.force and os.path.exists(dest_prompt): + tmuxp_echo( + colors.warning(f"{PrivatePath(dest_prompt)} exists.") + + " " + + colors.muted("Pick a new filename."), + ) + continue + + dest = dest_prompt + dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) + workspace_format = args.workspace_format + + valid_workspace_formats: list[CLIOutputFormatLiteral] = ["json", "yaml"] + + def is_valid_ext(stem: str | None) -> TypeGuard[CLIOutputFormatLiteral]: + return stem in valid_workspace_formats + + if not is_valid_ext(workspace_format): + + def extract_workspace_format( + val: str, + ) -> CLIOutputFormatLiteral | None: + suffix = pathlib.Path(val).suffix + if isinstance(suffix, str): + suffix = suffix.lower().lstrip(".") + if is_valid_ext(suffix): + return suffix + return None + + workspace_format = extract_workspace_format(dest) + if not is_valid_ext(workspace_format): + workspace_format_ = prompt_choices( + "Couldn't ascertain one of [{}] from file name. Convert to".format( + ", ".join(valid_workspace_formats), + ), + choices=t.cast("list[str]", valid_workspace_formats), + default="yaml", + color_mode=color_mode, + ) + assert is_valid_ext(workspace_format_) + workspace_format = workspace_format_ + + if workspace_format == "yaml": + workspace = configparser.dump( + fmt="yaml", + indent=2, + default_flow_style=False, + safe=True, + ) + elif workspace_format == "json": + workspace = configparser.dump(fmt="json", indent=2) + + if args.answer_yes or prompt_yes_no( + f"Save to {PrivatePath(dest)}?", + color_mode=color_mode, + ): + destdir = os.path.dirname(dest) + if not os.path.isdir(destdir): + os.makedirs(destdir) + pathlib.Path(dest).write_text( + workspace, + encoding=locale.getpreferredencoding(False), + ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) + + if not args.quiet: + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py new file mode 100644 index 0000000000..84446adddd --- /dev/null +++ b/src/tmuxp/cli/import_config.py @@ -0,0 +1,282 @@ +"""CLI for ``tmuxp import`` subcommand.""" + +from __future__ import annotations + +import locale +import logging +import os +import pathlib +import sys +import typing as t + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace import importers +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import ColorMode, Colors, build_description, get_color_mode +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +IMPORT_DESCRIPTION = build_description( + """ + Import workspaces from teamocil and tmuxinator configuration files. + """, + ( + ( + "teamocil", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + ], + ), + ( + "tmuxinator", + [ + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +def get_tmuxinator_dir() -> pathlib.Path: + """Return tmuxinator configuration directory. + + Checks for ``TMUXINATOR_CONFIG`` environmental variable. + + Returns + ------- + pathlib.Path : + absolute path to tmuxinator config directory + + See Also + -------- + :func:`tmuxp.workspace.importers.import_tmuxinator` + """ + if "TMUXINATOR_CONFIG" in os.environ: + return pathlib.Path(os.environ["TMUXINATOR_CONFIG"]).expanduser() + + return pathlib.Path("~/.tmuxinator/").expanduser() + + +def get_teamocil_dir() -> pathlib.Path: + """Return teamocil configuration directory. + + Returns + ------- + pathlib.Path : + absolute path to teamocil config directory + + See Also + -------- + :func:`tmuxp.workspace.importers.import_teamocil` + """ + return pathlib.Path("~/.teamocil/").expanduser() + + +def _resolve_path_no_overwrite(workspace_file: str) -> str: + path = pathlib.Path(workspace_file).resolve() + if path.exists(): + msg = f"{path} exists. Pick a new filename." + raise ValueError(msg) + return str(path) + + +def command_import( + workspace_file: str, + print_list: str, + parser: argparse.ArgumentParser, +) -> None: + """Import a teamocil/tmuxinator config.""" + + +def create_import_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``import`` subparser.""" + importsubparser = parser.add_subparsers( + title="commands", + description="valid commands", + help="additional help", + ) + + import_teamocil = importsubparser.add_parser( + "teamocil", + help="convert and import a teamocil config", + ) + + import_teamocilgroup = import_teamocil.add_mutually_exclusive_group(required=True) + teamocil_workspace_file = import_teamocilgroup.add_argument( + dest="workspace_file", + type=str, + nargs="?", + metavar="workspace-file", + help="checks current ~/.teamocil and current directory for yaml files", + ) + import_teamocil.set_defaults( + callback=command_import_teamocil, + import_subparser_name="teamocil", + ) + + import_tmuxinator = importsubparser.add_parser( + "tmuxinator", + help="convert and import a tmuxinator config", + ) + + import_tmuxinatorgroup = import_tmuxinator.add_mutually_exclusive_group( + required=True, + ) + tmuxinator_workspace_file = import_tmuxinatorgroup.add_argument( + dest="workspace_file", + type=str, + nargs="?", + metavar="workspace-file", + help="checks current ~/.tmuxinator and current directory for yaml files", + ) + + import_tmuxinator.set_defaults( + callback=command_import_tmuxinator, + import_subparser_name="tmuxinator", + ) + + try: + import shtab + + teamocil_workspace_file.complete = shtab.FILE # type: ignore + tmuxinator_workspace_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + return parser + + +class ImportConfigFn(t.Protocol): + """Typing for import configuration callback function.""" + + def __call__(self, workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Execute tmuxp import function.""" + ... + + +def import_config( + workspace_file: str, + importfunc: ImportConfigFn, + parser: argparse.ArgumentParser | None = None, + colors: Colors | None = None, +) -> None: + """Import a configuration from a workspace_file.""" + if colors is None: + colors = Colors(ColorMode.AUTO) + + existing_workspace_file = ConfigReader._from_file(pathlib.Path(workspace_file)) + cfg_reader = ConfigReader(importfunc(existing_workspace_file)) + + workspace_file_format = prompt_choices( + "Convert to", + choices=["yaml", "json"], + default="yaml", + color_mode=colors.mode, + ) + + if workspace_file_format == "yaml": + new_config = cfg_reader.dump("yaml", indent=2, default_flow_style=False) + elif workspace_file_format == "json": + new_config = cfg_reader.dump("json", indent=2) + else: + sys.exit(colors.error("Unknown config format.")) + + tmuxp_echo( + new_config + + colors.format_separator(63) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n", + ) + if prompt_yes_no( + "The new config *WILL* require adjusting afterwards. Save config?", + color_mode=colors.mode, + ): + dest = None + while not dest: + dest_path = prompt( + f"Save to [{PrivatePath(os.getcwd())}]", + value_proc=_resolve_path_no_overwrite, + color_mode=colors.mode, + ) + + # dest = dest_prompt + if prompt_yes_no( + f"Save to {PrivatePath(dest_path)}?", + color_mode=colors.mode, + ): + dest = dest_path + + pathlib.Path(dest).write_text( + new_config, + encoding=locale.getpreferredencoding(False), + ) + + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) + else: + tmuxp_echo( + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info(""), + ) + sys.exit() + + +def command_import_tmuxinator( + workspace_file: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp import tmuxinator`` subcommand. + + Converts a tmuxinator config from workspace_file to tmuxp format and import + it into tmuxp. + """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + workspace_file = find_workspace_file( + workspace_file, + workspace_dir=get_tmuxinator_dir(), + ) + import_config(workspace_file, importers.import_tmuxinator, colors=colors) + + +def command_import_teamocil( + workspace_file: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp import teamocil`` subcommand. + + Convert a teamocil config from workspace_file to tmuxp format and import + it into tmuxp. + """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + workspace_file = find_workspace_file( + workspace_file, + workspace_dir=get_teamocil_dir(), + ) + + import_config(workspace_file, importers.import_teamocil, colors=colors) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py new file mode 100644 index 0000000000..a9faf89bce --- /dev/null +++ b/src/tmuxp/cli/load.py @@ -0,0 +1,930 @@ +"""CLI for ``tmuxp load`` subcommand.""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib +import logging +import os +import pathlib +import shutil +import sys +import typing as t + +from libtmux.server import Server + +from tmuxp import exc, log, util +from tmuxp._internal import config_reader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace import loader +from tmuxp.workspace.builder import ( + WorkspaceBuilderProtocol, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir + +from ._colors import ColorMode, Colors, build_description, get_color_mode +from ._progress import ( + DEFAULT_OUTPUT_LINES, + SUCCESS_TEMPLATE, + Spinner, + _SafeFormatMap, + resolve_progress_format, +) +from .utils import prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: + """Temporarily raise StreamHandler level to WARNING while spinner is active. + + INFO/DEBUG log records are diagnostics for aggregators, not user-facing output; + the spinner is the user-facing progress channel. Restores original levels on exit. + """ + _log = logging.getLogger(logger_name) + saved: list[tuple[logging.StreamHandler[t.Any], int]] = [ + (h, h.level) + for h in _log.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + for h, _ in saved: + h.setLevel(logging.WARNING) + try: + yield + finally: + for h, level in saved: + h.setLevel(level) + + +LOAD_DESCRIPTION = build_description( + """ + Load tmuxp workspace file(s) and create or attach to a tmux session. + """, + ( + ( + None, + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + "tmuxp load -L other-socket myproject", + "tmuxp load -a myproject", + ], + ), + ), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from libtmux.session import Session + from typing_extensions import NotRequired, TypedDict + + from tmuxp.types import StrPath + + CLIColorsLiteral: TypeAlias = t.Literal[56, 88] + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + class OptionOverrides(TypedDict): + """Optional argument overrides for tmuxp load.""" + + detached: NotRequired[bool] + new_session_name: NotRequired[str | None] + + +class CLILoadNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp load command.""" + + workspace_files: list[str] + socket_name: str | None + socket_path: str | None + tmux_config_file: str | None + new_session_name: str | None + answer_yes: bool | None + detached: bool + append: bool | None + colors: CLIColorsLiteral | None + color: CLIColorModeLiteral + log_file: str | None + log_level: str + progress_format: str | None + panel_lines: int | None + no_progress: bool + + +def load_plugins( + session_config: dict[str, t.Any], + colors: Colors | None = None, +) -> list[t.Any]: + """Load and return plugins in workspace. + + Parameters + ---------- + session_config : dict + Session configuration dictionary. + colors : :class:`~tmuxp._internal.colors.Colors` | None + Colors instance for output formatting. If None, uses + :attr:`~tmuxp._internal.colors.ColorMode.AUTO`. + + Returns + ------- + list + List of loaded plugin instances. + + Examples + -------- + Empty config returns empty list: + + >>> from tmuxp.cli.load import load_plugins + >>> load_plugins({'session_name': 'test'}) + [] + + With explicit Colors instance: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> load_plugins({'session_name': 'test'}, colors=colors) + [] + """ + if colors is None: + colors = Colors(ColorMode.AUTO) + + plugins = [] + if "plugins" in session_config: + for plugin in session_config["plugins"]: + try: + module_name = plugin.split(".") + module_name = ".".join(module_name[:-1]) + plugin_name = plugin.split(".")[-1] + except AttributeError as error: + logger.debug("plugin load failed", exc_info=True) + tmuxp_echo( + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), + ) + sys.exit(1) + + try: + plugin = getattr(importlib.import_module(module_name), plugin_name) + plugins.append(plugin()) + except exc.TmuxpPluginException as error: + if not prompt_yes_no( + f"{colors.warning(str(error))}Skip loading {plugin_name}?", + default=True, + color_mode=colors.mode, + ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) + tmuxp_echo( + colors.warning("[Not Skipping]") + + " Plugin versions constraint not met. Exiting...", + ) + sys.exit(1) + except (ImportError, AttributeError) as error: + logger.debug("plugin import failed", exc_info=True) + tmuxp_echo( + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), + ) + sys.exit(1) + + return plugins + + +def _reattach(builder: WorkspaceBuilderProtocol, colors: Colors | None = None) -> None: + """ + Reattach session (depending on env being inside tmux already or not). + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + colors : :class:`~tmuxp._internal.colors.Colors` | None + Optional Colors instance for styled output. + + Notes + ----- + If ``TMUX`` environmental variable exists in the environment this script is + running, that means we're in a tmux client. So ``tmux switch-client`` will + load the session. + + If not, ``tmux attach-session`` loads the client to the target session. + """ + assert builder.session is not None + for plugin in builder.plugins: + plugin.reattach(builder.session) + proc = builder.session.cmd("display-message", "-p", "'#S'") + for line in proc.stdout: + tmuxp_echo(colors.info(line) if colors else line) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) + + if "TMUX" in os.environ: + builder.session.switch_client() + + else: + builder.session.attach() + + +def _load_attached( + builder: WorkspaceBuilderProtocol, + detached: bool, + pre_attach_hook: t.Callable[[], None] | None = None, +) -> None: + """ + Load workspace in new session. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + detached : bool + pre_attach_hook : callable, optional + called after build, before attach/switch_client; use to stop the spinner + so its cleanup sequences don't appear inside the tmux pane. + """ + builder.build() + assert builder.session is not None + + if pre_attach_hook is not None: + pre_attach_hook() + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' + tmux_env = os.environ.pop("TMUX") + + builder.session.switch_client() # switch client to new session + + os.environ["TMUX"] = tmux_env # set TMUX back again + elif not detached: + builder.session.attach() + + +def _load_detached( + builder: WorkspaceBuilderProtocol, + colors: Colors | None = None, + pre_output_hook: t.Callable[[], None] | None = None, +) -> None: + """ + Load workspace in new session but don't attach. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + colors : :class:`~tmuxp._internal.colors.Colors` | None + Optional Colors instance for styled output. + pre_output_hook : Callable | None + Called after build but before printing, e.g. to stop a spinner. + """ + builder.build() + + assert builder.session is not None + + if pre_output_hook is not None: + pre_output_hook() + + msg = "Session created in detached state." + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") + + +def _load_append_windows_to_current_session( + builder: WorkspaceBuilderProtocol, +) -> None: + """ + Load workspace as new windows in current session. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + """ + current_attached_session = builder.find_current_attached_session() + builder.build(current_attached_session, append=True) + assert builder.session is not None + + +def _setup_plugins(builder: WorkspaceBuilderProtocol) -> Session: + """Execute hooks for plugins running after ``before_script``. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + """ + assert builder.session is not None + for plugin in builder.plugins: + plugin.before_script(builder.session) + + return builder.session + + +def _dispatch_build( + builder: WorkspaceBuilderProtocol, + detached: bool, + append: bool, + answer_yes: bool, + cli_colors: Colors, + pre_attach_hook: t.Callable[[], None] | None = None, + on_error_hook: t.Callable[[], None] | None = None, + pre_prompt_hook: t.Callable[[], None] | None = None, +) -> Session | None: + """Dispatch the build to the correct load path and handle errors. + + Handles the detached/attached/append switching logic and the + ``TmuxpException`` error-recovery prompt. Extracted so the + spinner-enabled and spinner-disabled paths share one implementation. + + Parameters + ---------- + builder : WorkspaceBuilder + Configured workspace builder. + detached : bool + Load session in detached state. + append : bool + Append windows to the current session. + answer_yes : bool + Skip interactive prompts. + cli_colors : :class:`~tmuxp._internal.colors.Colors` + Colors instance for styled output. + pre_attach_hook : callable, optional + Called before attach/switch_client (e.g. stop spinner). + on_error_hook : callable, optional + Called before showing the error-recovery prompt (e.g. stop spinner). + pre_prompt_hook : callable, optional + Called before any interactive prompt (e.g. stop spinner so ANSI + escape sequences don't garble the terminal during user input). + + Returns + ------- + Session | None + The built session, or ``None`` if the user killed it on error. + + Examples + -------- + >>> from tmuxp.cli.load import _dispatch_build + >>> callable(_dispatch_build) + True + """ + try: + if detached: + _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + return _setup_plugins(builder) + + if append: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + + # append and answer_yes have no meaning if specified together + if answer_yes: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + return _setup_plugins(builder) + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + if pre_prompt_hook is not None: + pre_prompt_hook() + msg = ( + "Already inside TMUX, switch to session? yes/no\n" + "Or (a)ppend windows in the current active session?\n[y/n/a]" + ) + options = ["y", "n", "a"] + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + + if choice == "y": + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + elif choice == "a": + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder, cli_colors) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + except exc.TmuxpException as e: + if on_error_hook is not None: + on_error_hook() + logger.debug("workspace build failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) + + if choice == "k": + if builder.session is not None: + builder.session.kill() + tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") + elif choice == "a": + _reattach(builder, cli_colors) + else: + sys.exit() + return None + finally: + builder.on_progress = None + builder.on_before_script = None + builder.on_script_output = None + builder.on_build_event = None + + return _setup_plugins(builder) + + +def load_workspace( + workspace_file: StrPath, + socket_name: str | None = None, + socket_path: str | None = None, + tmux_config_file: str | None = None, + new_session_name: str | None = None, + colors: int | None = None, + detached: bool = False, + answer_yes: bool = False, + append: bool = False, + cli_colors: Colors | None = None, + progress_format: str | None = None, + panel_lines: int | None = None, + no_progress: bool = False, +) -> Session | None: + """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. + + Parameters + ---------- + workspace_file : list of str + paths or session names to workspace files + socket_name : str, optional + ``tmux -L `` + socket_path: str, optional + ``tmux -S `` + new_session_name: str, options + ``tmux new -s `` + colors : int, optional + Force tmux to support 256 or 88 colors. + detached : bool + Force detached state. default False. + answer_yes : bool + Assume yes when given prompt to attach in new session. + Default False. + append : bool + Assume current when given prompt to append windows in same session. + Default False. + cli_colors : :class:`~tmuxp._internal.colors.Colors`, optional + Colors instance for CLI output formatting. If None, uses + :attr:`~tmuxp._internal.colors.ColorMode.AUTO`. + progress_format : str, optional + Spinner format preset name or custom format string with tokens. + panel_lines : int, optional + Number of script-output lines shown in the spinner panel. + Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). + Override via ``TMUXP_PROGRESS_LINES`` environment variable. + no_progress : bool + Disable the progress spinner entirely. Default False. + Also disabled when ``TMUXP_PROGRESS=0``. + + Notes + ----- + tmuxp will check and load a workspace file. The file will use + :class:`~tmuxp._internal.config_reader.ConfigReader` to load a JSON/YAML + into a :class:`dict`. Then :func:`~tmuxp.workspace.loader.expand` and + :func:`~tmuxp.workspace.loader.trickle` will be used to expand any + shorthands, template variables, or file paths relative to where the + config/script is executed from. + + :func:`~tmuxp.workspace.loader.expand` accepts the directory of the config + file, so the user's workspace can resolve absolute paths relative to where + the workspace file is. In otherwords, if a workspace file at + */var/moo/hi.yaml* has *./* in its workspaces, we want to be sure any file + path with *./* is relative to */var/moo*, not the user's PWD. + + A :class:`libtmux.Server` object is created. No tmux server is started yet, + just the object. + + The prepared workspace and its server object is passed into an instance + of :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. + + A sanity check against :meth:`libtmux.common.which` is ran. It will raise + an exception if tmux isn't found. + + If a tmux session under the same name as ``session_name`` in the tmuxp + workspace exists, tmuxp offers to attach the session. Currently, tmuxp + does not allow appending a workspace / incremental building on top of a + current session (pull requests are welcome!). + + :meth:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol.build` + will build the session in the background via using tmux's detached state + (``-d``). + + After the session (workspace) is built, unless the user decided to load + the session in the background via ``tmuxp -d`` (which is in the spirit + of tmux's ``-d``), we need to prompt the user to attach the session. + + If the user is already inside a tmux client, which we detect via the + ``TMUX`` environment variable bring present, we will prompt the user to + switch their current client to it. + + If they're outside of tmux client - in a plain-old PTY - we will + automatically ``attach``. + + If an exception is raised during the building of the workspace, tmuxp will + prompt to cleanup (``$ tmux kill-session``) the session on the user's + behalf. An exception raised during this process means it's not easy to + predict how broken the session is. + """ + # Initialize CLI colors if not provided + if cli_colors is None: + cli_colors = Colors(ColorMode.AUTO) + + # get the canonical path, eliminating any symlinks + if isinstance(workspace_file, (str, os.PathLike)): + workspace_file = pathlib.Path(workspace_file) + + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, + ) + _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + if _progress_disabled: + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + 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 {} + + # shapes workspaces relative to config / profile file location + expanded_workspace = loader.expand( + raw_workspace, + cwd=os.path.dirname(workspace_file), + ) + + # Overridden session name + if new_session_name: + expanded_workspace["session_name"] = new_session_name + + # propagate workspace inheritance (e.g. session -> window, window -> pane) + expanded_workspace = loader.trickle(expanded_workspace) + + t = Server( # create tmux server object + socket_name=socket_name, + socket_path=socket_path, + config_file=tmux_config_file, + colors=colors, + ) + + shutil.which("tmux") # raise exception if tmux not found + + # Builder resolution + creation — outside spinner so plugin prompts are safe. + # Trusted import roots (absent config -> [] -> a no-op sandbox) stay on + # sys.path for the import and instantiation; each build dispatch re-enters + # the sandbox so build-time lazy imports resolve. + try: + builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) + with prepended_sys_path(builder_paths): + builder_cls = resolve_builder_class(expanded_workspace) + builder = builder_cls( + session_config=expanded_workspace, + plugins=load_plugins(expanded_workspace, colors=cli_colors), + server=t, + ) + except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) + tmuxp_echo( + cli_colors.warning("[Warning]") + + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", + ) + return None + except exc.WorkspaceBuilderError as e: + logger.debug("workspace builder resolution failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Builder Error]") + f" {e}") + sys.exit(1) + + session_name = expanded_workspace["session_name"] + + # Session-exists check — outside spinner so prompt_yes_no is safe + if builder.session_exists(session_name) and not append: + if not detached and ( + answer_yes + or prompt_yes_no( + f"{cli_colors.highlight(session_name)} is already running. Attach?", + default=True, + color_mode=cli_colors.mode, + ) + ): + _reattach(builder, cli_colors) + return None + + if _progress_disabled: + _private_path = str(PrivatePath(workspace_file)) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) + if result is not None: + summary = "" + try: + win_count = len(result.windows) + pane_count = sum(len(w.panes) for w in result.windows) + summary_parts: list[str] = [] + if win_count: + summary_parts.append(f"{win_count} win") + if pane_count: + summary_parts.append(f"{pane_count} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + except Exception: + logger.debug("session gone before summary", exc_info=True) + ctx = { + "session": cli_colors.highlight(session_name), + "workspace_path": cli_colors.info(_private_path), + "summary": cli_colors.muted(summary) if summary else "", + } + checkmark = cli_colors.success("\u2713") + tmuxp_echo( + f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" + ) + return result + + # Spinner wraps only the actual build phase + _progress_fmt = resolve_progress_format( + progress_format + if progress_format is not None + else os.getenv("TMUXP_PROGRESS_FORMAT", "default") + ) + _panel_lines_env = os.getenv("TMUXP_PROGRESS_LINES") + if _panel_lines_env: + try: + _panel_lines_env_int: int | None = int(_panel_lines_env) + except ValueError: + _panel_lines_env_int = None + else: + _panel_lines_env_int = None + _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _private_path = str(PrivatePath(workspace_file)) + _spinner = Spinner( + message=( + f"Loading workspace: {cli_colors.highlight(session_name)} ({_private_path})" + ), + color_mode=cli_colors.mode, + progress_format=_progress_fmt, + output_lines=_panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES, + workspace_path=_private_path, + ) + _success_emitted = False + + def _emit_success() -> None: + nonlocal _success_emitted + if _success_emitted: + return + _success_emitted = True + _spinner.success() + + with ( + _silence_stream_handlers(), + _spinner as spinner, + ): + builder.on_build_event = spinner.on_build_event + _resolved_panel = ( + _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES + ) + if _resolved_panel != 0: + builder.on_script_output = spinner.add_output_line + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result + + +def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``load`` subcommand.""" + workspace_files = parser.add_argument( + "workspace_files", + nargs="+", + metavar="workspace-file", + help="filepath to session or filename of session in tmuxp workspace directory", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket_name", + action="store", + help="passthru to tmux(1) -L", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket_path", + action="store", + help="passthru to tmux(1) -S", + ) + + tmux_config_file = parser.add_argument( + "-f", + dest="tmux_config_file", + metavar="tmux_config_file", + help="passthru to tmux(1) -f", + ) + + parser.add_argument( + "-s", + dest="new_session_name", + metavar="new_session_name", + help="start new session with new session name", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "-d", + dest="detached", + action="store_true", + help="load the session without attaching it", + ) + parser.add_argument( + "-a", + "--append", + dest="append", + action="store_true", + help="load workspace, appending windows to the current session", + ) + colorsgroup = parser.add_mutually_exclusive_group() + + colorsgroup.add_argument( + "-2", + dest="colors", + action="store_const", + const=256, + help="force tmux to assume the terminal supports 256 colours.", + ) + + colorsgroup.add_argument( + "-8", + dest="colors", + action="store_const", + const=88, + help="like -2, but indicates that the terminal supports 88 colours.", + ) + parser.set_defaults(colors=None) + + log_file = parser.add_argument( + "--log-file", + metavar="file_path", + action="store", + help="file to log errors/output to", + ) + + parser.add_argument( + "--progress-format", + metavar="FORMAT", + dest="progress_format", + default=None, + help=( + "Spinner line format: preset name " + "(default, minimal, window, pane, verbose) " + "or a format string with tokens " + "{session}, {window}, {progress}, {window_progress}, {pane_progress}, etc. " + "Env: TMUXP_PROGRESS_FORMAT" + ), + ) + + parser.add_argument( + "--progress-lines", + metavar="N", + dest="panel_lines", + type=int, + default=None, + help=( + "Number of script-output lines shown in the spinner panel (default: 3). " + "0 hides the panel entirely (script output goes to stdout). " + "-1 shows unlimited lines (capped to terminal height). " + "Env: TMUXP_PROGRESS_LINES" + ), + ) + + parser.add_argument( + "--no-progress", + dest="no_progress", + action="store_true", + default=False, + help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), + ) + + try: + import shtab + + workspace_files.complete = shtab.FILE # type: ignore + tmux_config_file.complete = shtab.FILE # type: ignore + log_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + return parser + + +def command_load( + args: CLILoadNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Load a tmux workspace from each WORKSPACE_FILE. + + WORKSPACE_FILE is a specifier for a workspace file. + + If WORKSPACE_FILE is a path to a directory, tmuxp will search it for + ".tmuxp.{yaml,yml,json}". + + If WORKSPACE_FILE is has no directory component and only a filename, e.g. + "myworkspace.yaml", tmuxp will search the users's workspace directory for that + file. + + If WORKSPACE_FILE has no directory component, and only a name with no extension, + e.g. "myworkspace", tmuxp will search the users's workspace directory for any + file with the extension ".yaml", ".yml", or ".json" that matches that name. + + If multiple workspace files that match a given WORKSPACE_FILE are found, tmuxp + will warn and pick the first one found. + + If multiple WORKSPACE_FILEs are provided, workspaces will be created for all of + them. The last one provided will be attached. The others will be created in + detached mode. + """ + util.oh_my_zsh_auto_title() + + # Create Colors instance based on CLI --color flag + cli_colors = Colors(get_color_mode(args.color)) + + if args.log_file: + log.setup_log_file(args.log_file, args.log_level) + + if args.workspace_files is None or len(args.workspace_files) == 0: + tmuxp_echo(cli_colors.error("Enter at least one config")) + if parser is not None: + parser.print_help() + sys.exit() + return + + last_idx = len(args.workspace_files) - 1 + original_detached_option = args.detached + original_new_session_name = args.new_session_name + + for idx, workspace_file in enumerate(args.workspace_files): + workspace_file = find_workspace_file( + workspace_file, + workspace_dir=get_workspace_dir(), + ) + + detached = original_detached_option + new_session_name = original_new_session_name + + if last_idx > 0 and idx < last_idx: + detached = True + new_session_name = None + + 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, + ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py new file mode 100644 index 0000000000..9b293d5eca --- /dev/null +++ b/src/tmuxp/cli/ls.py @@ -0,0 +1,646 @@ +"""CLI for ``tmuxp ls`` subcommand. + +List and display workspace configuration files. + +Examples +-------- +>>> from tmuxp.cli.ls import WorkspaceInfo + +Create workspace info from file path: + +>>> import pathlib +>>> ws = WorkspaceInfo( +... name="dev", +... path="~/.tmuxp/dev.yaml", +... format="yaml", +... size=256, +... mtime="2024-01-15T10:30:00", +... session_name="development", +... source="global", +... ) +>>> ws["name"] +'dev' +>>> ws["source"] +'global' +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import logging +import pathlib +import typing as t + +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS +from tmuxp.workspace.finders import ( + find_local_workspace_files, + get_workspace_dir, + get_workspace_dir_candidates, +) + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode, get_output_mode + +logger = logging.getLogger(__name__) + +LS_DESCRIPTION = build_description( + """ + List workspace files in the tmuxp configuration directory. + """, + ( + ( + None, + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + ], + ), + ( + "Machine-readable output examples", + [ + "tmuxp ls --json", + "tmuxp ls --json --full", + "tmuxp ls --ndjson", + "tmuxp ls --json | jq '.workspaces[].name'", + ], + ), + ), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class WorkspaceInfo(t.TypedDict): + """Workspace file information for JSON output. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + format : str + File format (yaml or json). + size : int + File size in bytes. + mtime : str + Modification time in ISO format. + session_name : str | None + Session name from config if parseable. + source : str + Source location: "local" (cwd/parents) or "global" (~/.tmuxp/). + """ + + name: str + path: str + format: str + size: int + mtime: str + session_name: str | None + source: str + + +class CLILsNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp ls command. + + Examples + -------- + >>> ns = CLILsNamespace() + >>> ns.color = "auto" + >>> ns.color + 'auto' + """ + + color: CLIColorModeLiteral + tree: bool + output_json: bool + output_ndjson: bool + full: bool + + +def create_ls_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_ls_subparser(parser) + >>> result is parser + True + """ + parser.add_argument( + "--tree", + action="store_true", + help="display workspaces grouped by directory", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + parser.add_argument( + "--full", + action="store_true", + help="include full config content in output", + ) + return parser + + +def _get_workspace_info( + filepath: pathlib.Path, + *, + source: str = "global", + include_config: bool = False, +) -> dict[str, t.Any]: + """Extract metadata from a workspace file. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + source : str + Source location: "local" or "global". Default "global". + include_config : bool + If True, include full parsed config content. Default False. + + Returns + ------- + dict[str, Any] + Workspace metadata dictionary. Includes 'config' key when include_config=True. + + Examples + -------- + >>> content = "session_name: test-session" + chr(10) + "windows: []" + >>> yaml_file = tmp_path / "test.yaml" + >>> _ = yaml_file.write_text(content) + >>> info = _get_workspace_info(yaml_file) + >>> info['session_name'] + 'test-session' + >>> info['format'] + 'yaml' + >>> info['source'] + 'global' + >>> info_local = _get_workspace_info(yaml_file, source="local") + >>> info_local['source'] + 'local' + >>> info_full = _get_workspace_info(yaml_file, include_config=True) + >>> 'config' in info_full + True + >>> info_full['config']['session_name'] + 'test-session' + """ + stat = filepath.stat() + ext = filepath.suffix.lower() + file_format = "json" if ext == ".json" else "yaml" + + # Try to extract session_name and optionally full config + session_name: str | None = None + config_content: dict[str, t.Any] | None = None + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = config.content.get("session_name") + if include_config: + config_content = config.content + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If we can't parse it, just skip session_name + pass + + result: dict[str, t.Any] = { + "name": filepath.stem, + "path": str(PrivatePath(filepath)), + "format": file_format, + "size": stat.st_size, + "mtime": datetime.datetime.fromtimestamp( + stat.st_mtime, + tz=datetime.timezone.utc, + ).isoformat(), + "session_name": session_name, + "source": source, + } + + if include_config: + result["config"] = config_content + + return result + + +def _render_config_tree(config: dict[str, t.Any], colors: Colors) -> list[str]: + """Render config windows/panes as tree lines for human output. + + Parameters + ---------- + config : dict[str, Any] + Parsed config content. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager. + + Returns + ------- + list[str] + Lines of formatted tree output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> config = { + ... "session_name": "dev", + ... "windows": [ + ... {"window_name": "editor", "layout": "main-horizontal"}, + ... {"window_name": "shell"}, + ... ], + ... } + >>> lines = _render_config_tree(config, colors) + >>> "editor" in lines[0] + True + >>> "shell" in lines[1] + True + """ + lines: list[str] = [] + windows = config.get("windows", []) + + for i, window in enumerate(windows): + if not isinstance(window, dict): + continue + + is_last_window = i == len(windows) - 1 + prefix = "└── " if is_last_window else "├── " + child_prefix = " " if is_last_window else "│ " + + # Window line + window_name = window.get("window_name", f"window {i}") + layout = window.get("layout", "") + layout_info = f" [{layout}]" if layout else "" + lines.append(f"{prefix}{colors.info(window_name)}{colors.muted(layout_info)}") + + # Panes + panes = window.get("panes", []) + for j, pane in enumerate(panes): + is_last_pane = j == len(panes) - 1 + pane_prefix = "└── " if is_last_pane else "├── " + + # Get pane command summary + if isinstance(pane, dict): + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + cmd_str = cmds + elif isinstance(cmds, list) and cmds: + cmd_str = str(cmds[0]) + else: + cmd_str = "" + elif isinstance(pane, str): + cmd_str = pane + else: + cmd_str = "" + + # Truncate long commands + if len(cmd_str) > 40: + cmd_str = cmd_str[:37] + "..." + + pane_info = f": {cmd_str}" if cmd_str else "" + lines.append( + f"{child_prefix}{pane_prefix}{colors.muted(f'pane {j}')}{pane_info}" + ) + + return lines + + +def _render_global_workspace_dirs( + formatter: OutputFormatter, + colors: Colors, + global_dir_candidates: list[dict[str, t.Any]], +) -> None: + """Render global workspace directories section. + + Parameters + ---------- + formatter : OutputFormatter + Output formatter. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager. + global_dir_candidates : list[dict[str, Any]] + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> candidates = [ + ... {"path": "~/.tmuxp", "source": "Legacy", "exists": True, + ... "workspace_count": 5, "active": True}, + ... {"path": "~/.config/tmuxp", "source": "XDG", "exists": False, + ... "workspace_count": 0, "active": False}, + ... ] + >>> _render_global_workspace_dirs(formatter, colors, candidates) + + Global workspace directories: + Legacy: ~/.tmuxp (5 workspaces, active) + XDG: ~/.config/tmuxp (not found) + """ + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + + +def _output_flat( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces in flat list format. + + Groups workspaces by source (local vs global) for human output. + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_flat(workspaces, formatter, colors) + Global workspaces: + dev + """ + # Separate by source for human output grouping + local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] + global_workspaces = [ws for ws in workspaces if ws["source"] == "global"] + + def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: + """Output a single workspace.""" + formatter.emit(ws) + path_info = f" {colors.info(ws['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(ws['name'])}{path_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output local workspaces first (closest to user's context) + if local_workspaces: + formatter.emit_text(colors.heading("Local workspaces:")) + for ws in local_workspaces: + output_workspace(ws, show_path=True) + + # Output global workspaces with active directory in header + if global_workspaces: + if local_workspaces: + formatter.emit_text("") # Blank line separator + + # Find active directory for header + active_dir = "" + if global_dir_candidates: + for candidate in global_dir_candidates: + if candidate["active"]: + active_dir = candidate["path"] + break + + if active_dir: + formatter.emit_text(colors.heading(f"Global workspaces ({active_dir}):")) + else: + formatter.emit_text(colors.heading("Global workspaces:")) + + for ws in global_workspaces: + output_workspace(ws, show_path=False) + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + +def _output_tree( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces grouped by directory (tree view). + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_tree(workspaces, formatter, colors) + + ~/.tmuxp + dev + """ + # Group by parent directory + by_directory: dict[str, list[dict[str, t.Any]]] = {} + for ws in workspaces: + # Extract parent directory from path + parent = str(pathlib.Path(ws["path"]).parent) + by_directory.setdefault(parent, []).append(ws) + + # Output grouped + for directory in sorted(by_directory.keys()): + dir_workspaces = by_directory[directory] + + # Human output: directory header + formatter.emit_text(f"\n{colors.highlight(directory)}") + + for ws in dir_workspaces: + # JSON/NDJSON output + formatter.emit(ws) + + # Human output: indented workspace name + ws_name = ws["name"] + ws_session = ws.get("session_name") + session_info = "" + if ws_session and ws_session != ws_name: + session_info = f" {colors.muted(f'→ {ws_session}')}" + formatter.emit_text(f" {colors.highlight(ws_name)}{session_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + +def command_ls( + args: CLILsNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp ls`` subcommand. + + Lists both local workspaces (from cwd and parent directories) and + global workspaces (from ~/.tmuxp/). + + Parameters + ---------- + args : CLILsNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ + """ + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode and options + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + tree = args.tree if args else False + full = args.full if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get global workspace directory candidates + global_dir_candidates = get_workspace_dir_candidates() + + # 1. Collect local workspace files (cwd and parents) + local_files = find_local_workspace_files() + workspaces: list[dict[str, t.Any]] = [ + _get_workspace_info(f, source="local", include_config=full) for f in local_files + ] + + # 2. Collect global workspace files (~/.tmuxp/) + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + _get_workspace_info(f, source="global", include_config=full) + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + # Still show global workspace directories even with no workspaces + if output_mode == OutputMode.HUMAN: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + elif output_mode == OutputMode.JSON: + # Output structured JSON with empty workspaces + output_data = { + "workspaces": [], + "global_workspace_dirs": global_dir_candidates, + } + formatter.emit_object(output_data) + # NDJSON: just output nothing for empty workspaces + return + + # JSON mode: output structured object instead of using formatter + if output_mode == OutputMode.JSON: + output_data = { + "workspaces": workspaces, + "global_workspace_dirs": global_dir_candidates, + } + formatter.emit_object(output_data) + return + + # Human and NDJSON output + if tree: + _output_tree( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + else: + _output_flat( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + + formatter.finalize() diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py new file mode 100644 index 0000000000..cd1eb37bd7 --- /dev/null +++ b/src/tmuxp/cli/search.py @@ -0,0 +1,1303 @@ +"""CLI for ``tmuxp search`` subcommand. + +Search workspace configuration files by name, session, path, and content. + +Examples +-------- +>>> from tmuxp.cli.search import SearchToken, normalize_fields + +Parse field aliases to canonical names: + +>>> normalize_fields(["s", "name"]) +('session_name', 'name') + +Create search tokens from query terms: + +>>> from tmuxp.cli.search import parse_query_terms, DEFAULT_FIELDS +>>> tokens = parse_query_terms(["name:dev", "editor"], default_fields=DEFAULT_FIELDS) +>>> tokens[0] +SearchToken(fields=('name',), pattern='dev') +>>> tokens[1] +SearchToken(fields=('name', 'session_name', 'path', 'window', 'pane'), pattern='editor') +""" + +from __future__ import annotations + +import argparse +import json +import logging +import pathlib +import re +import typing as t + +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS +from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, get_output_mode + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + +#: Field name aliases for search queries +FIELD_ALIASES: dict[str, str] = { + "name": "name", + "n": "name", + "session": "session_name", + "session_name": "session_name", + "s": "session_name", + "path": "path", + "p": "path", + "window": "window", + "w": "window", + "pane": "pane", +} + +#: Valid field names after alias resolution +VALID_FIELDS: frozenset[str] = frozenset( + {"name", "session_name", "path", "window", "pane"} +) + +#: Default fields to search when no field prefix is specified +DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path", "window", "pane") + + +class SearchToken(t.NamedTuple): + """Parsed search token with target fields and raw pattern. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search (e.g., ('name', 'session_name')). + pattern : str + Raw search pattern before regex compilation. + + Examples + -------- + >>> token = SearchToken(fields=("name",), pattern="dev") + >>> token.fields + ('name',) + >>> token.pattern + 'dev' + """ + + fields: tuple[str, ...] + pattern: str + + +class SearchPattern(t.NamedTuple): + """Compiled search pattern with regex and metadata. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search. + raw : str + Original pattern string before compilation. + regex : re.Pattern[str] + Compiled regex pattern for matching. + + Examples + -------- + >>> import re + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> pattern.fields + ('name',) + >>> bool(pattern.regex.search("development")) + True + """ + + fields: tuple[str, ...] + raw: str + regex: re.Pattern[str] + + +class InvalidFieldError(ValueError): + """Raised when an invalid field name is specified. + + Examples + -------- + >>> raise InvalidFieldError("invalid") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + + def __init__(self, field: str) -> None: + valid = ", ".join(sorted(FIELD_ALIASES.keys())) + super().__init__(f"Unknown search field: '{field}'. Valid fields: {valid}") + self.field = field + + +def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: + """Normalize field names using aliases. + + Parameters + ---------- + fields : list[str] | None + Field names or aliases to normalize. If None, returns DEFAULT_FIELDS. + + Returns + ------- + tuple[str, ...] + Tuple of canonical field names. + + Raises + ------ + InvalidFieldError + If a field name is not recognized. + + Examples + -------- + >>> normalize_fields(None) + ('name', 'session_name', 'path', 'window', 'pane') + + >>> normalize_fields(["s", "n"]) + ('session_name', 'name') + + >>> normalize_fields(["session_name", "path"]) + ('session_name', 'path') + + >>> normalize_fields(["invalid"]) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + if fields is None: + return DEFAULT_FIELDS + + result: list[str] = [] + for field in fields: + field_lower = field.lower() + if field_lower not in FIELD_ALIASES: + raise InvalidFieldError(field) + canonical = FIELD_ALIASES[field_lower] + if canonical not in result: + result.append(canonical) + + return tuple(result) + + +def _parse_field_prefix(term: str) -> tuple[str | None, str]: + """Extract field prefix from a search term. + + Parameters + ---------- + term : str + Search term, possibly with field prefix (e.g., "name:dev"). + + Returns + ------- + tuple[str | None, str] + Tuple of (field_prefix, pattern). field_prefix is None if no prefix. + + Examples + -------- + >>> _parse_field_prefix("name:dev") + ('name', 'dev') + + >>> _parse_field_prefix("s:myproject") + ('s', 'myproject') + + >>> _parse_field_prefix("development") + (None, 'development') + + >>> _parse_field_prefix("path:/home/user") + ('path', '/home/user') + + >>> _parse_field_prefix("window:") + ('window', '') + """ + if ":" not in term: + return None, term + + # Split on first colon only + prefix, _, pattern = term.partition(":") + prefix_lower = prefix.lower() + + # Check if prefix is a valid field alias + if prefix_lower in FIELD_ALIASES: + return prefix, pattern + + # Not a valid field prefix, treat entire term as pattern + return None, term + + +def parse_query_terms( + terms: list[str], + *, + default_fields: tuple[str, ...] = DEFAULT_FIELDS, +) -> list[SearchToken]: + """Parse query terms into search tokens. + + Each term can optionally have a field prefix (e.g., "name:dev"). + Terms without prefixes search the default fields. + + Parameters + ---------- + terms : list[str] + Query terms to parse. + default_fields : tuple[str, ...] + Fields to search when no prefix is specified. + + Returns + ------- + list[SearchToken] + List of parsed search tokens. + + Raises + ------ + InvalidFieldError + If a field prefix is not recognized. + + Examples + -------- + >>> tokens = parse_query_terms(["dev"]) + >>> tokens[0].fields + ('name', 'session_name', 'path', 'window', 'pane') + >>> tokens[0].pattern + 'dev' + + >>> tokens = parse_query_terms(["name:dev", "s:prod"]) + >>> tokens[0] + SearchToken(fields=('name',), pattern='dev') + >>> tokens[1] + SearchToken(fields=('session_name',), pattern='prod') + + >>> tokens = parse_query_terms(["window:editor", "shell"]) + >>> tokens[0].fields + ('window',) + >>> tokens[1].fields + ('name', 'session_name', 'path', 'window', 'pane') + + Unknown prefixes are treated as literal patterns (allows URLs, etc.): + + >>> tokens = parse_query_terms(["http://example.com"]) + >>> tokens[0].pattern + 'http://example.com' + >>> tokens[0].fields # Searches default fields + ('name', 'session_name', 'path', 'window', 'pane') + """ + result: list[SearchToken] = [] + + for term in terms: + if not term: + continue + + prefix, pattern = _parse_field_prefix(term) + + # Validate and resolve field prefix, or use defaults + fields = normalize_fields([prefix]) if prefix is not None else default_fields + + if pattern: # Skip empty patterns + result.append(SearchToken(fields=fields, pattern=pattern)) + + return result + + +def _has_uppercase(pattern: str) -> bool: + """Check if pattern contains uppercase letters. + + Used for smart-case detection. + + Parameters + ---------- + pattern : str + Pattern to check. + + Returns + ------- + bool + True if pattern contains at least one uppercase letter. + + Examples + -------- + >>> _has_uppercase("dev") + False + + >>> _has_uppercase("Dev") + True + + >>> _has_uppercase("DEV") + True + + >>> _has_uppercase("123") + False + + >>> _has_uppercase("") + False + """ + return any(c.isupper() for c in pattern) + + +def compile_search_patterns( + tokens: list[SearchToken], + *, + ignore_case: bool = False, + smart_case: bool = False, + fixed_strings: bool = False, + word_regexp: bool = False, +) -> list[SearchPattern]: + """Compile search tokens into regex patterns. + + Parameters + ---------- + tokens : list[SearchToken] + Parsed search tokens to compile. + ignore_case : bool + If True, always ignore case. Default False. + smart_case : bool + If True, ignore case unless pattern has uppercase. Default False. + fixed_strings : bool + If True, treat patterns as literal strings, not regex. Default False. + word_regexp : bool + If True, match whole words only. Default False. + + Returns + ------- + list[SearchPattern] + List of compiled search patterns. + + Raises + ------ + re.error + If a pattern is invalid regex (when fixed_strings=False). + + Examples + -------- + Basic compilation: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens) + >>> patterns[0].raw + 'dev' + >>> bool(patterns[0].regex.search("development")) + True + + Case-insensitive matching: + + >>> tokens = [SearchToken(fields=("name",), pattern="DEV")] + >>> patterns = compile_search_patterns(tokens, ignore_case=True) + >>> bool(patterns[0].regex.search("development")) + True + + Smart-case (uppercase = case-sensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="Dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("Developer")) + True + >>> bool(patterns[0].regex.search("developer")) + False + + Smart-case (lowercase = case-insensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("DEVELOPMENT")) + True + + Fixed strings (escape regex metacharacters): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev.*")] + >>> patterns = compile_search_patterns(tokens, fixed_strings=True) + >>> bool(patterns[0].regex.search("dev.*project")) + True + >>> bool(patterns[0].regex.search("development")) + False + + Word boundaries: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, word_regexp=True) + >>> bool(patterns[0].regex.search("my dev project")) + True + >>> bool(patterns[0].regex.search("development")) + False + """ + result: list[SearchPattern] = [] + + for token in tokens: + pattern_str = token.pattern + + # Escape for literal matching if requested + if fixed_strings: + pattern_str = re.escape(pattern_str) + + # Add word boundaries if requested + if word_regexp: + pattern_str = rf"\b{pattern_str}\b" + + # Determine case sensitivity + flags = 0 + if ignore_case or (smart_case and not _has_uppercase(token.pattern)): + flags |= re.IGNORECASE + + compiled = re.compile(pattern_str, flags) + result.append( + SearchPattern( + fields=token.fields, + raw=token.pattern, + regex=compiled, + ) + ) + + return result + + +class WorkspaceFields(t.TypedDict): + """Extracted searchable fields from a workspace file. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + session_name : str + Session name from config, or empty string if not found. + windows : list[str] + List of window names from config. + panes : list[str] + List of pane commands/shell_commands from config. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + >>> fields["name"] + 'dev' + """ + + name: str + path: str + session_name: str + windows: list[str] + panes: list[str] + + +class WorkspaceSearchResult(t.TypedDict): + """Search result for a workspace that matched. + + Attributes + ---------- + filepath : str + Absolute path to the workspace file. + source : str + Source location: "local" or "global". + fields : WorkspaceFields + Extracted searchable fields. + matches : dict[str, list[str]] + Mapping of field name to matched strings for highlighting. + + Examples + -------- + >>> result: WorkspaceSearchResult = { + ... "filepath": "/home/user/.tmuxp/dev.yaml", + ... "source": "global", + ... "fields": { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor"], + ... "panes": [], + ... }, + ... "matches": {"name": ["dev"]}, + ... } + >>> result["source"] + 'global' + """ + + filepath: str + source: str + fields: WorkspaceFields + matches: dict[str, list[str]] + + +def extract_workspace_fields(filepath: pathlib.Path) -> WorkspaceFields: + """Extract searchable fields from a workspace file. + + Parses the workspace configuration and extracts name, path, session_name, + window names, and pane commands for searching. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + + Returns + ------- + WorkspaceFields + Dictionary of extracted fields. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> content = ''' + ... session_name: my-project + ... windows: + ... - window_name: editor + ... panes: + ... - vim + ... - shell_command: git status + ... - window_name: shell + ... ''' + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + >>> fields = extract_workspace_fields(temp_path) + >>> fields["session_name"] + 'my-project' + >>> sorted(fields["windows"]) + ['editor', 'shell'] + >>> 'vim' in fields["panes"] + True + >>> temp_path.unlink() + """ + # Basic fields from file + name = filepath.stem + path = str(PrivatePath(filepath)) + + # Try to parse config for session_name, windows, panes + session_name = "" + windows: list[str] = [] + panes: list[str] = [] + + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = str(config.content.get("session_name", "")) + + # Extract window names and pane commands + for window in config.content.get("windows", []): + if not isinstance(window, dict): + continue + + # Window name + if window_name := window.get("window_name"): + windows.append(str(window_name)) + + # Pane commands + for pane in window.get("panes", []): + if isinstance(pane, str): + panes.append(pane) + elif isinstance(pane, dict): + # shell_command can be str or list + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + panes.append(cmds) + elif isinstance(cmds, list): + panes.extend(str(cmd) for cmd in cmds if cmd) + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If config parsing fails, continue with empty content fields + pass + + return WorkspaceFields( + name=name, + path=path, + session_name=session_name, + windows=windows, + panes=panes, + ) + + +def _get_field_values(fields: WorkspaceFields, field_name: str) -> list[str]: + """Get values for a field, normalizing to list. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields. + field_name : str + Canonical field name to retrieve. + + Returns + ------- + list[str] + List of values for the field. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim"], + ... } + >>> _get_field_values(fields, "name") + ['dev'] + >>> _get_field_values(fields, "windows") + ['editor', 'shell'] + >>> _get_field_values(fields, "window") + ['editor', 'shell'] + """ + # Handle field name aliasing (window -> windows, pane -> panes) + if field_name == "window": + field_name = "windows" + elif field_name == "pane": + field_name = "panes" + + # Access fields directly for type safety + if field_name == "name": + return [fields["name"]] if fields["name"] else [] + if field_name == "path": + return [fields["path"]] if fields["path"] else [] + if field_name == "session_name": + return [fields["session_name"]] if fields["session_name"] else [] + if field_name == "windows": + return fields["windows"] + if field_name == "panes": + return fields["panes"] + + return [] + + +def evaluate_match( + fields: WorkspaceFields, + patterns: list[SearchPattern], + *, + match_any: bool = False, +) -> tuple[bool, dict[str, list[str]]]: + """Evaluate if workspace fields match search patterns. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields to search. + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). + If False, ALL patterns must match (AND logic). Default False. + + Returns + ------- + tuple[bool, dict[str, list[str]]] + Tuple of (matched, {field_name: [matched_strings]}). + The matches dict contains actual matched text for highlighting. + + Examples + -------- + >>> import re + >>> fields: WorkspaceFields = { + ... "name": "dev-project", + ... "path": "~/.tmuxp/dev-project.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + + Single pattern match: + + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> matched, matches = evaluate_match(fields, [pattern]) + >>> matched + True + >>> "name" in matches + True + + AND logic (default) - all patterns must match: + + >>> p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + >>> p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=False) + >>> matched + False + + OR logic - any pattern can match: + + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=True) + >>> matched + True + + Window field search: + + >>> p_win = SearchPattern( + ... fields=("window",), + ... raw="editor", + ... regex=re.compile("editor"), + ... ) + >>> matched, matches = evaluate_match(fields, [p_win]) + >>> matched + True + >>> "window" in matches + True + """ + all_matches: dict[str, list[str]] = {} + pattern_results: list[bool] = [] + + for pattern in patterns: + pattern_matched = False + + for field_name in pattern.fields: + values = _get_field_values(fields, field_name) + + for value in values: + if match := pattern.regex.search(value): + pattern_matched = True + # Store matched text for highlighting + if field_name not in all_matches: + all_matches[field_name] = [] + all_matches[field_name].append(match.group()) + + pattern_results.append(pattern_matched) + + # Apply match logic + if match_any: + final_matched = any(pattern_results) + else: + final_matched = all(pattern_results) if pattern_results else False + + return final_matched, all_matches + + +def find_search_matches( + workspaces: list[tuple[pathlib.Path, str]], + patterns: list[SearchPattern], + *, + match_any: bool = False, + invert_match: bool = False, +) -> list[WorkspaceSearchResult]: + """Find workspaces matching search patterns. + + Parameters + ---------- + workspaces : list[tuple[pathlib.Path, str]] + List of (filepath, source) tuples to search. Source is "local" or "global". + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). Default False (AND). + invert_match : bool + If True, return workspaces that do NOT match. Default False. + + Returns + ------- + list[WorkspaceSearchResult] + List of matching workspace results with match information. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> import re + >>> content = "session_name: dev-session" + chr(10) + "windows: []" + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + + >>> pattern = SearchPattern( + ... fields=("session_name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> results = find_search_matches([(temp_path, "global")], [pattern]) + >>> len(results) + 1 + >>> results[0]["source"] + 'global' + + Invert match returns non-matching workspaces: + + >>> pattern_nomatch = SearchPattern( + ... fields=("name",), + ... raw="nonexistent", + ... regex=re.compile("nonexistent"), + ... ) + >>> results = find_search_matches( + ... [(temp_path, "global")], [pattern_nomatch], invert_match=True + ... ) + >>> len(results) + 1 + >>> temp_path.unlink() + """ + results: list[WorkspaceSearchResult] = [] + + for filepath, source in workspaces: + fields = extract_workspace_fields(filepath) + matched, matches = evaluate_match(fields, patterns, match_any=match_any) + + # Apply invert logic + if invert_match: + matched = not matched + + if matched: + results.append( + WorkspaceSearchResult( + filepath=str(filepath), + source=source, + fields=fields, + matches=matches, + ) + ) + + return results + + +def highlight_matches( + text: str, + patterns: list[SearchPattern], + *, + colors: Colors, +) -> str: + """Highlight regex matches in text. + + Parameters + ---------- + text : str + Text to search and highlight. + patterns : list[SearchPattern] + Compiled search patterns (uses their regex attribute). + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager for highlighting. + + Returns + ------- + str + Text with matches highlighted, or original text if no matches. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> highlight_matches("development", [pattern], colors=colors) + 'development' + + With colors enabled (ALWAYS mode): + + >>> colors_on = Colors(ColorMode.ALWAYS) + >>> result = highlight_matches("development", [pattern], colors=colors_on) + >>> "dev" in result + True + >>> chr(27) in result # Contains ANSI escape + True + """ + if not patterns: + return text + + # Collect all match spans + spans: list[tuple[int, int]] = [] + for pattern in patterns: + spans.extend((m.start(), m.end()) for m in pattern.regex.finditer(text)) + + if not spans: + return text + + # Sort and merge overlapping spans + spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in spans: + if merged and start <= merged[-1][1]: + # Overlapping or adjacent, extend previous + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + # Build result with highlights + result: list[str] = [] + pos = 0 + for start, end in merged: + # Add non-matching text before this match + if pos < start: + result.append(text[pos:start]) + # Add highlighted match + result.append(colors.highlight(text[start:end])) + pos = end + + # Add any remaining text after last match + if pos < len(text): + result.append(text[pos:]) + + return "".join(result) + + +def _output_search_results( + results: list[WorkspaceSearchResult], + patterns: list[SearchPattern], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output search results in human-readable or JSON format. + + Parameters + ---------- + results : list[WorkspaceSearchResult] + Search results to output. + patterns : list[SearchPattern] + Patterns used for highlighting. + formatter : OutputFormatter + Output formatter for JSON/NDJSON/human modes. + colors : :class:`~tmuxp._internal.colors.Colors` + Color manager. + """ + if not results: + formatter.emit_text(colors.warning("No matching workspaces found.")) + return + + # Group by source for human output + local_results = [r for r in results if r["source"] == "local"] + global_results = [r for r in results if r["source"] == "global"] + + def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: + """Output a single search result.""" + fields = result["fields"] + + # JSON/NDJSON output: emit structured data + json_data = { + "name": fields["name"], + "path": fields["path"], + "session_name": fields["session_name"], + "source": result["source"], + "matched_fields": list(result["matches"].keys()), + "matches": result["matches"], + } + formatter.emit(json_data) + + # Human output: formatted text with highlighting + name_display = highlight_matches(fields["name"], patterns, colors=colors) + path_info = f" {colors.info(fields['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(name_display)}{path_info}") + + # Show matched session_name if different from name + session_name = fields["session_name"] + if session_name and session_name != fields["name"]: + session_display = highlight_matches(session_name, patterns, colors=colors) + formatter.emit_text(f" session: {session_display}") + + # Show matched windows + if result["matches"].get("window"): + window_names = [ + highlight_matches(w, patterns, colors=colors) for w in fields["windows"] + ] + if window_names: + formatter.emit_text(f" windows: {', '.join(window_names)}") + + # Show matched panes + if result["matches"].get("pane"): + pane_cmds = fields["panes"][:3] # Limit to first 3 + pane_displays = [ + highlight_matches(p, patterns, colors=colors) for p in pane_cmds + ] + if len(fields["panes"]) > 3: + pane_displays.append("...") + if pane_displays: + formatter.emit_text(f" panes: {', '.join(pane_displays)}") + + # Output local results first + if local_results: + formatter.emit_text(colors.heading("Local workspaces:")) + for result in local_results: + output_result(result, show_path=True) + + # Output global results + if global_results: + if local_results: + formatter.emit_text("") # Blank line separator + formatter.emit_text(colors.heading("Global workspaces:")) + for result in global_results: + output_result(result, show_path=False) + + +SEARCH_DESCRIPTION = build_description( + """ + Search workspace files by name, session, path, window, or pane content. + """, + ( + ( + None, + [ + "tmuxp search dev", + 'tmuxp search "my.*project"', + "tmuxp search name:dev", + "tmuxp search s:development", + ], + ), + ( + "Field-scoped search", + [ + "tmuxp search window:editor", + "tmuxp search pane:vim", + "tmuxp search p:~/.tmuxp", + ], + ), + ( + "Matching options", + [ + "tmuxp search -i DEV", + "tmuxp search -S DevProject", + "tmuxp search -F 'my.project'", + "tmuxp search --word-regexp test", + ], + ), + ( + "Multiple patterns", + [ + "tmuxp search dev production", + "tmuxp search --any dev production", + "tmuxp search -v staging", + ], + ), + ( + "Machine-readable output examples", + [ + "tmuxp search --json dev", + "tmuxp search --ndjson dev | jq '.name'", + ], + ), + ), +) + + +class CLISearchNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp search command. + + Examples + -------- + >>> ns = CLISearchNamespace() + >>> ns.query_terms = ["dev"] + >>> ns.query_terms + ['dev'] + """ + + color: CLIColorModeLiteral + query_terms: list[str] + field: list[str] | None + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + invert_match: bool + match_any: bool + output_json: bool + output_ndjson: bool + print_help: t.Callable[[], None] + + +def create_search_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``search`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_search_subparser(parser) + >>> result is parser + True + """ + # Positional arguments + parser.add_argument( + "query_terms", + nargs="*", + metavar="PATTERN", + help="search patterns (prefix with field: for field-scoped search)", + ) + + # Field restriction + parser.add_argument( + "-f", + "--field", + action="append", + metavar="FIELD", + help="restrict search to field(s): name, session/s, path/p, window/w, pane", + ) + + # Matching options + parser.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="case-insensitive matching", + ) + parser.add_argument( + "-S", + "--smart-case", + action="store_true", + help="case-insensitive unless pattern has uppercase", + ) + parser.add_argument( + "-F", + "--fixed-strings", + action="store_true", + help="treat patterns as literal strings, not regex", + ) + parser.add_argument( + "-w", + "--word-regexp", + action="store_true", + help="match whole words only", + ) + parser.add_argument( + "-v", + "--invert-match", + action="store_true", + help="show workspaces that do NOT match", + ) + parser.add_argument( + "--any", + dest="match_any", + action="store_true", + help="match ANY pattern (OR logic); default is ALL (AND logic)", + ) + + # Output format + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + + # Store print_help for use when no arguments provided + parser.set_defaults(print_help=parser.print_help) + + return parser + + +def command_search( + args: CLISearchNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp search`` subcommand. + + Searches workspace files in local (cwd and parents) and global (~/.tmuxp/) + directories. + + Parameters + ---------- + args : CLISearchNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_search() searches workspaces with given patterns + """ + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get query terms + query_terms = args.query_terms if args else [] + + if not query_terms: + if args and hasattr(args, "print_help"): + args.print_help() + return + + # Parse and compile patterns + try: + # Get default fields (possibly restricted by --field) + default_fields = normalize_fields(args.field if args else None) + tokens = parse_query_terms(query_terms, default_fields=default_fields) + + if not tokens: + formatter.emit_text(colors.warning("No valid search patterns.")) + formatter.finalize() + return + + patterns = compile_search_patterns( + tokens, + ignore_case=args.ignore_case if args else False, + smart_case=args.smart_case if args else False, + fixed_strings=args.fixed_strings if args else False, + word_regexp=args.word_regexp if args else False, + ) + except InvalidFieldError as e: + formatter.emit_text(colors.error(str(e))) + formatter.finalize() + return + except re.error as e: + formatter.emit_text(colors.error(f"Invalid regex pattern: {e}")) + formatter.finalize() + return + + # Collect workspaces: local (cwd + parents) + global (~/.tmuxp/) + workspaces: list[tuple[pathlib.Path, str]] = [] + + # Local workspace files + local_files = find_local_workspace_files() + workspaces.extend((f, "local") for f in local_files) + + # Global workspace files + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + (f, "global") + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + formatter.finalize() + return + + # Find matches + results = find_search_matches( + workspaces, + patterns, + match_any=args.match_any if args else False, + invert_match=args.invert_match if args else False, + ) + + # Output results + _output_search_results(results, patterns, formatter, colors) + formatter.finalize() diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py new file mode 100644 index 0000000000..57a5ae8e4b --- /dev/null +++ b/src/tmuxp/cli/shell.py @@ -0,0 +1,257 @@ +"""CLI for ``tmuxp shell`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import typing as t + +from libtmux.server import Server + +from tmuxp import util +from tmuxp._compat import PY3, PYMINOR + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +SHELL_DESCRIPTION = build_description( + """ + Launch interactive Python shell with tmux server, session, window and pane. + """, + ( + ( + None, + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + "tmuxp shell --best", + ], + ), + ), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + CLIColorsLiteral: TypeAlias = t.Literal[56, 88] + CLIShellLiteral: TypeAlias = t.Literal[ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + + +class CLIShellNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp shell command.""" + + color: CLIColorModeLiteral + session_name: str + socket_name: str | None + socket_path: str | None + colors: CLIColorsLiteral | None + log_file: str | None + window_name: str | None + command: str | None + shell: CLIShellLiteral | None + use_pythonrc: bool + use_vi_mode: bool + + +def create_shell_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``shell`` subcommand.""" + parser.add_argument("session_name", metavar="session-name", nargs="?") + parser.add_argument("window_name", metavar="window-name", nargs="?") + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + parser.add_argument( + "-c", + dest="command", + help="instead of opening shell, execute python code in libtmux and exit", + ) + + shells = parser.add_mutually_exclusive_group() + shells.add_argument( + "--best", + dest="shell", + const="best", + action="store_const", + help="use best shell available in site packages", + default="best", + ) + shells.add_argument( + "--pdb", + dest="shell", + const="pdb", + action="store_const", + help="use plain pdb", + ) + shells.add_argument( + "--code", + dest="shell", + const="code", + action="store_const", + help="use stdlib's code.interact()", + ) + shells.add_argument( + "--ptipython", + dest="shell", + const="ptipython", + action="store_const", + help="use ptpython + ipython", + ) + shells.add_argument( + "--ptpython", + dest="shell", + const="ptpython", + action="store_const", + help="use ptpython", + ) + shells.add_argument( + "--ipython", + dest="shell", + const="ipython", + action="store_const", + help="use ipython", + ) + shells.add_argument( + "--bpython", + dest="shell", + const="bpython", + action="store_const", + help="use bpython", + ) + + parser.add_argument( + "--use-pythonrc", + dest="use_pythonrc", + action="store_true", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--no-startup", + dest="use_pythonrc", + action="store_false", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--use-vi-mode", + dest="use_vi_mode", + action="store_true", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + parser.add_argument( + "--no-vi-mode", + dest="use_vi_mode", + action="store_false", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + return parser + + +def command_shell( + args: CLIShellNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp shell`` for tmux server, session, window and pane. + + Priority given to loaded session/window/pane objects: + + - session_name and window_name arguments + - current shell: envvar ``TMUX_PANE`` for determining window and session + - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.active_window`, + :attr:`libtmux.Window.active_pane` + """ + color_mode = get_color_mode(args.color) + cli_colors = Colors(color_mode) + + # If inside a server, detect socket_path + env_tmux = os.getenv("TMUX") + if env_tmux is not None and isinstance(env_tmux, str): + env_socket_path = pathlib.Path(env_tmux.split(",")[0]) + if ( + env_socket_path.exists() + and args.socket_path is None + and args.socket_name is None + ): + args.socket_path = str(env_socket_path) + + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) + + server.raise_if_dead() + + current_pane = util.get_current_pane(server=server) + + session = util.get_session( + server=server, + session_name=args.session_name, + current_pane=current_pane, + ) + + window = util.get_window( + session=session, + window_name=args.window_name, + current_pane=current_pane, + ) + + pane = util.get_pane(window=window, current_pane=current_pane) + + if args.command is not None: + exec(args.command) + elif args.shell == "pdb" or ( + os.getenv("PYTHONBREAKPOINT") and PY3 and PYMINOR >= 7 + ): + from tmuxp._compat import breakpoint as tmuxp_breakpoint + + tmuxp_echo( + cli_colors.muted("Launching ") + + cli_colors.highlight("pdb", bold=False) + + cli_colors.muted(" shell..."), + ) + tmuxp_breakpoint() + return + else: + from tmuxp.shell import launch + + shell_name = args.shell or "best" + tmuxp_echo( + cli_colors.muted("Launching ") + + cli_colors.highlight(shell_name, bold=False) + + cli_colors.muted(" shell for session ") + + cli_colors.info(session.name or "") + + cli_colors.muted("..."), + ) + + launch( + shell=args.shell, + use_pythonrc=args.use_pythonrc, # shell: code + use_vi_mode=args.use_vi_mode, # shell: ptpython, ptipython + # tmux environment / libtmux variables + server=server, + session=session, + window=window, + pane=pane, + ) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py new file mode 100644 index 0000000000..5429b7867e --- /dev/null +++ b/src/tmuxp/cli/utils.py @@ -0,0 +1,233 @@ +"""CLI utility helpers for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + strip_ansi, + style, + unstyle, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from collections.abc import Callable, Sequence + +# Re-export for backward compatibility +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "prompt", + "prompt_bool", + "prompt_choices", + "prompt_yes_no", + "strip_ansi", + "style", + "tmuxp_echo", + "unstyle", +] + + +def prompt( + name: str, + default: str | None = None, + value_proc: Callable[[str], str] | None = None, + *, + color_mode: ColorMode | None = None, +) -> str: + """Return user input from command line. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + + Returns + ------- + str + + See Also + -------- + prompt_bool + Prompt for a boolean response. + prompt_choices + Prompt for a value from a fixed choice list. + prompt_yes_no + Prompt for a yes/no response. + + Notes + ----- + The prompt helpers are adapted from + `flask-script `_; see the + `flask-script license `_. + """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) + # Use PrivatePath to mask home directory in displayed default + display_default = str(PrivatePath(default)) if default else None + prompt_ = name + ( + (display_default and " " + colors.info(f"[{display_default}]")) or "" + ) + prompt_ += (name.endswith("?") and " ") or ": " + while True: + rv = input(prompt_) or default + # Validate with value_proc only if we have a string value + if rv is not None and value_proc is not None and callable(value_proc): + try: + value_proc(rv) + except ValueError as e: + return prompt( + str(e), + default=default, + value_proc=value_proc, + color_mode=color_mode, + ) + + if rv: + return rv + if default is not None: + return default + # No input and no default - loop to re-prompt + + +def prompt_bool( + name: str, + default: bool = False, + yes_choices: Sequence[t.Any] | None = None, + no_choices: Sequence[t.Any] | None = None, + *, + color_mode: ColorMode | None = None, +) -> bool: + """Return True / False by prompting user input from command line. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + yes_choices : + default 'y', 'yes', '1', 'on', 'true', 't' + no_choices : + default 'n', 'no', '0', 'off', 'false', 'f' + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + + Returns + ------- + bool + """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) + yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") + no_choices = no_choices or ("n", "no", "0", "off", "false", "f") + + if default is None: + prompt_choice = "y/n" + elif default is True: + prompt_choice = "Y/n" + else: + prompt_choice = "y/N" + + prompt_ = name + " " + colors.muted(f"[{prompt_choice}]") + prompt_ += (name.endswith("?") and " ") or ": " + + while True: + rv = input(prompt_) + if not rv: + return default + if rv.lower() in yes_choices: + return True + if rv.lower() in no_choices: + return False + + +def prompt_yes_no( + name: str, + default: bool = True, + *, + color_mode: ColorMode | None = None, +) -> bool: + """:meth:`prompt_bool()` returning yes by default. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + """ + return prompt_bool(name, default=default, color_mode=color_mode) + + +def prompt_choices( + name: str, + choices: Sequence[str | tuple[str, str]], + default: str | None = None, + no_choice: Sequence[str] = ("none",), + *, + color_mode: ColorMode | None = None, +) -> str | None: + """Return user input from command line from set of provided choices. + + Parameters + ---------- + name : + prompt text + choices : + Sequence of available choices. Each choice may be a single string or + a (key, value) tuple where key is used for matching. + default : + default value if no input provided. + no_choice : + acceptable list of strings for "null choice" + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + + Returns + ------- + str + """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) + choices_: list[str] = [] + options: list[str] = [] + + for choice in choices: + if isinstance(choice, str): + options.append(choice) + elif isinstance(choice, tuple): + options.append(f"{choice} [{choice[0]}]") + choice = choice[0] + choices_.append(choice) + + choices_str = colors.muted(f"({', '.join(options)})") + default_str = " " + colors.info(f"[{default}]") if default else "" + prompt_text = f"{name} - {choices_str}{default_str}" + + while True: + prompt_ = prompt_text + ": " + rv = input(prompt_) or default + if not rv or rv == default: + return default + rv = rv.lower() + if rv in no_choice: + return None + if rv in choices_: + return rv + tmuxp_echo( + colors.warning(f"Invalid choice '{rv}'. ") + + f"Please choose from: {', '.join(choices_)}" + ) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py new file mode 100644 index 0000000000..5168aa10d0 --- /dev/null +++ b/src/tmuxp/exc.py @@ -0,0 +1,246 @@ +"""Exceptions for tmuxp.""" + +from __future__ import annotations + +import logging + +from libtmux._internal.query_list import ObjectDoesNotExist + +from ._compat import implements_to_string + +logger = logging.getLogger(__name__) + + +class TmuxpException(Exception): + """Base Exception for Tmuxp Errors.""" + + +class WorkspaceError(TmuxpException): + """Error parsing tmuxp workspace data.""" + + +class SessionNotFound(TmuxpException): + """tmux session not found.""" + + def __init__( + self, + session_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Session not found" + if session_target is not None: + msg += f": {session_target}" + return super().__init__(msg, *args, **kwargs) + + +class WindowNotFound(TmuxpException): + """tmux window not found.""" + + def __init__( + self, + window_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Window not found" + if window_target is not None: + msg += f": {window_target}" + return super().__init__(msg, *args, **kwargs) + + +class PaneNotFound(TmuxpException): + """tmux pane not found.""" + + def __init__( + self, + pane_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Pane not found" + if pane_target is not None: + msg += f": {pane_target}" + return super().__init__(msg, *args, **kwargs) + + +class EmptyWorkspaceException(WorkspaceError): + """Workspace file is empty.""" + + def __init__( + self, + *args: object, + **kwargs: object, + ) -> None: + return super().__init__("Session configuration is empty.", *args, **kwargs) + + +class SessionMissingWorkspaceException(WorkspaceError, ObjectDoesNotExist): + """Session missing while loading tmuxp workspace.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + "No session object exists for WorkspaceBuilder. " + "Tip: Add session_name in constructor or run WorkspaceBuilder.build()", + *args, + **kwargs, + ) + + +class ActiveSessionMissingWorkspaceException(WorkspaceError): + """Active session cannot be found while loading tmuxp workspace.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__("No session active.", *args, **kwargs) + + +class WorkspaceBuilderError(WorkspaceError): + """Base error for resolving and validating a workspace builder.""" + + +class WorkspaceBuilderNotFound(WorkspaceBuilderError): + """Configured ``workspace_builder`` could not be resolved. + + >>> print(WorkspaceBuilderNotFound("nope")) + Workspace builder 'nope' could not be found.... + """ + + def __init__( + self, + target: str, + available: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Workspace builder {target!r} could not be found." + if available: + names = ", ".join(sorted(available)) + msg += f" Available entry point builders: {names}." + else: + msg += ( + " Provide a Python dotted path (e.g. 'package.module:Builder') " + "or register an entry point in the 'tmuxp.workspace_builders' group." + ) + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderImportError(WorkspaceBuilderError): + """Configured ``workspace_builder`` failed to import. + + >>> print(WorkspaceBuilderImportError("pkg:B")) + Could not import workspace builder 'pkg:B'.... + """ + + def __init__( + self, + target: str, + reason: str | None = None, + paths: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Could not import workspace builder {target!r}." + if reason: + msg += f" {reason}" + if paths: + msg += f" Searched trusted paths: {', '.join(paths)}." + msg += ( + " Confirm the builder is importable, or add its directory to " + "'workspace_builder_paths'." + ) + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilder(WorkspaceBuilderError): + """Resolved ``workspace_builder`` object is not a usable builder. + + >>> print(InvalidWorkspaceBuilder("pkg:B")) + 'pkg:B' is not a valid workspace builder. + """ + + def __init__( + self, + target: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"{target!r} is not a valid workspace builder" + msg += f": {reason}" if reason else "." + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderPathError(WorkspaceBuilderError): + """A ``workspace_builder_paths`` entry is not a usable directory. + + >>> print(WorkspaceBuilderPathError("/x")) + workspace_builder_paths entry is invalid: /x.... + """ + + def __init__( + self, + path: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"workspace_builder_paths entry is invalid: {path}." + msg += f" {reason}" if reason else " Each entry must be an existing directory." + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilderOption(WorkspaceBuilderError): + """A ``workspace_builder_options`` value is invalid. + + >>> print(InvalidWorkspaceBuilderOption("bad")) + Invalid workspace_builder_options: bad + """ + + def __init__(self, reason: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Invalid workspace_builder_options: {reason}", + *args, + **kwargs, + ) + + +class TmuxpPluginException(TmuxpException): + """Base Exception for Tmuxp Errors.""" + + +class BeforeLoadScriptNotExists(OSError): + """Raises if shell script could not be found.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + + self.strerror = f"before_script file '{self.strerror}' doesn't exist." + + +@implements_to_string +class BeforeLoadScriptError(Exception): + """Shell script execution error. + + Replaces :class:`subprocess.CalledProcessError` for + :func:`tmuxp.util.run_before_script`. + """ + + def __init__( + self, + returncode: int, + cmd: str, + output: str | None = None, + ) -> None: + self.returncode = returncode + self.cmd = cmd + self.output = output + self.message = ( + f"before_script failed with returncode {self.returncode}.\n" + f"command: {self.cmd}\n" + "Error output:\n" + f"{self.output}" + ) + + def __str__(self) -> str: + """Return shell error message.""" + return self.message diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py new file mode 100644 index 0000000000..d4867f052a --- /dev/null +++ b/src/tmuxp/log.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python +"""Log utilities for tmuxp.""" + +from __future__ import annotations + +import logging +import sys +import time +import typing as t + +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all + +logger = logging.getLogger(__name__) + +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" + +LEVEL_COLORS = { + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", +} + + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs + + +def setup_logger( + logger: logging.Logger | None = None, + level: str = "INFO", +) -> None: + """Configure tmuxp's logging for CLI use. + + Can checks for any existing loggers to prevent loading handlers twice. + + Parameters + ---------- + logger : :class:`logging.Logger` + logger instance for tmuxp + """ + if not logger: # if no logger exists, make one + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) + + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) + + logger.setLevel(level) + + +def set_style( + message: str, + stylized: bool, + style_before: str = "", + style_after: str = "", + prefix: str = "", + suffix: str = "", +) -> str: + """Stylize terminal logging output.""" + if stylized: + return prefix + style_before + message + style_after + suffix + + return prefix + message + suffix + + +class LogFormatter(logging.Formatter): + """Format logs for tmuxp.""" + + def template( + self: logging.Formatter, + record: logging.LogRecord, + stylized: bool = False, + **kwargs: t.Any, + ) -> str: + """ + Return the prefix for the log message. Template for Formatter. + + Parameters + ---------- + record : :class:`logging.LogRecord` + Object passed from :meth:`logging.Formatter.format`. + + Returns + ------- + str + Template for logger message. + """ + reset = _ANSI_RESET + levelname = set_style( + "(%(levelname)s)", + stylized, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, + suffix=" ", + ) + asctime = set_style( + "%(asctime)s", + stylized, + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), + prefix="[", + suffix="]", + ) + name = set_style( + "%(name)s", + stylized, + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), + prefix=" ", + suffix=" ", + ) + + if stylized: + return reset + levelname + asctime + name + reset + + return levelname + asctime + name + + def __init__(self, color: bool = True, **kwargs: t.Any) -> None: + logging.Formatter.__init__(self, **kwargs) + + def format(self, record: logging.LogRecord) -> str: + """Format a log record.""" + try: + record.message = record.getMessage() + except Exception as e: + record.message = f"Bad message ({e!r}): {record.__dict__!r}" + + date_format = "%H:%M:%S" + formatting = self.converter(record.created) + record.asctime = time.strftime(date_format, formatting) + + prefix = self.template(record) % record.__dict__ + + parts = prefix.split(record.message) + formatted = prefix + " " + record.message + return formatted.replace("\n", "\n" + parts[0] + " ") + + +def debug_log_template( + self: type[logging.Formatter], + record: logging.LogRecord, + stylized: bool | None = False, + **kwargs: t.Any, +) -> str: + """ + Return the prefix for the log message. Template for Formatter. + + Parameters + ---------- + record : :class:`logging.LogRecord` + Object passed from :meth:`logging.Formatter.format`. + + Returns + ------- + str + Log template. + """ + reset = _ANSI_RESET + levelname = ( + LEVEL_COLORS.get(record.levelname, "") + + _ANSI_BRIGHT + + "(%(levelname)1.1s)" + + _ANSI_RESET + + " " + ) + asctime = ( + "[" + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + + "%(asctime)s" + + _ANSI_FG_RESET + + _ANSI_RESET + + "]" + ) + name = ( + " " + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + + "%(name)s" + + _ANSI_FG_RESET + + _ANSI_RESET + + " " + ) + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) + lineno = ( + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + + ":" + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + + "%(lineno)d" + ) + + return reset + levelname + asctime + name + module_funcName + lineno + reset + + +class DebugLogFormatter(LogFormatter): + """Provides greater technical details than standard log Formatter.""" + + template = debug_log_template + + +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) + + +def tmuxp_echo( + message: str | None = None, + file: t.TextIO | None = None, +) -> None: + """Print user-facing CLI output. + + Parameters + ---------- + message : str | None + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. + + Examples + -------- + >>> tmuxp_echo("Session loaded") + Session loaded + + >>> tmuxp_echo("Warning message") + Warning message + """ + if message is None: + return + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py new file mode 100644 index 0000000000..d5a65b8024 --- /dev/null +++ b/src/tmuxp/plugin.py @@ -0,0 +1,296 @@ +"""Plugin system for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +import libtmux +from libtmux._compat import LegacyVersion as Version +from libtmux.common import get_version + +from .__about__ import __version__ +from .exc import TmuxpPluginException + +logger = logging.getLogger(__name__) + +#: Minimum version of tmux required to run tmuxp +TMUX_MIN_VERSION = "3.2" + +#: Most recent version of tmux supported +TMUX_MAX_VERSION = None + +#: Minimum version of libtmux required to run libtmux +LIBTMUX_MIN_VERSION = "0.8.3" + +#: Most recent version of libtmux supported +LIBTMUX_MAX_VERSION = None + +#: Minimum version of tmuxp required to use plugins +TMUXP_MIN_VERSION = "1.6.0" + +#: Most recent version of tmuxp +TMUXP_MAX_VERSION = None + + +if t.TYPE_CHECKING: + from typing import TypeGuard + + from libtmux.session import Session + from libtmux.window import Window + from typing_extensions import TypedDict, Unpack + + from ._internal.types import PluginConfigSchema + + class VersionConstraints(TypedDict): + """Version constraints mapping for a tmuxp plugin.""" + + version: Version | str + vmin: str + vmax: str | None + incompatible: list[t.Any | str] + + class TmuxpPluginVersionConstraints(TypedDict): + """Version constraints for a tmuxp plugin.""" + + tmux: VersionConstraints + tmuxp: VersionConstraints + libtmux: VersionConstraints + + +class Config(t.TypedDict): + """tmuxp plugin configuration mapping.""" + + plugin_name: str + tmux_min_version: str + tmux_max_version: str | None + tmux_version_incompatible: list[str] | None + libtmux_min_version: str + libtmux_max_version: str | None + libtmux_version_incompatible: list[str] | None + tmuxp_min_version: str + tmuxp_max_version: str | None + tmuxp_version_incompatible: list[str] | None + + +DEFAULT_CONFIG: Config = { + "plugin_name": "tmuxp-plugin", + "tmux_min_version": TMUX_MIN_VERSION, + "tmux_max_version": TMUX_MAX_VERSION, + "tmux_version_incompatible": None, + "libtmux_min_version": LIBTMUX_MIN_VERSION, + "libtmux_max_version": LIBTMUX_MAX_VERSION, + "libtmux_version_incompatible": None, + "tmuxp_min_version": TMUXP_MIN_VERSION, + "tmuxp_max_version": TMUXP_MAX_VERSION, + "tmuxp_version_incompatible": None, +} + + +def validate_plugin_config(config: PluginConfigSchema) -> TypeGuard[Config]: + """Return True if tmuxp plugin configuration valid, also upcasts.""" + return isinstance(config, dict) + + +def setup_plugin_config( + config: PluginConfigSchema, + default_config: Config = DEFAULT_CONFIG, +) -> Config: + """Initialize tmuxp plugin configuration.""" + new_config = config.copy() + for default_key, default_value in default_config.items(): + if default_key not in new_config: + new_config[default_key] = default_value # type:ignore + + assert validate_plugin_config(new_config) + + return new_config + + +class TmuxpPlugin: + """Base class for a tmuxp plugin.""" + + def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: + """ + Initialize plugin. + + The default version values are set to the versions that the plugin + system requires. + + Parameters + ---------- + plugin_name : str + Name of the child plugin. Used in error message plugin fails to + load + + tmux_min_version : str + Min version of tmux that the plugin supports + + tmux_max_version : str + Min version of tmux that the plugin supports + + tmux_version_incompatible : list + Versions of tmux that are incompatible with the plugin + + libtmux_min_version : str + Min version of libtmux that the plugin supports + + libtmux_max_version : str + Max version of libtmux that the plugin supports + + libtmux_version_incompatible : list + Versions of libtmux that are incompatible with the plugin + + tmuxp_min_version : str + Min version of tmuxp that the plugin supports + + tmuxp_max_version : str + Max version of tmuxp that the plugin supports + + tmuxp_version_incompatible : list + Versions of tmuxp that are incompatible with the plugin + + """ + config = setup_plugin_config(config=kwargs) + self.plugin_name = config["plugin_name"] + + # Dependency versions + self.tmux_version = get_version() + self.libtmux_version = libtmux.__about__.__version__ + self.tmuxp_version = Version(__version__) + + self.version_constraints: TmuxpPluginVersionConstraints = { + "tmux": { + "version": self.tmux_version, + "vmin": config["tmux_min_version"], + "vmax": config["tmux_max_version"], + "incompatible": config["tmux_version_incompatible"] or [], + }, + "libtmux": { + "version": self.libtmux_version, + "vmin": config["libtmux_min_version"], + "vmax": config["libtmux_max_version"], + "incompatible": config["libtmux_version_incompatible"] or [], + }, + "tmuxp": { + "version": self.tmuxp_version, + "vmin": config["tmuxp_min_version"], + "vmax": config["tmuxp_max_version"], + "incompatible": config["tmuxp_version_incompatible"] or [], + }, + } + + self._version_check() + + def _version_check(self) -> None: + """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) + for dep, constraints in self.version_constraints.items(): + assert isinstance(constraints, dict) + try: + assert self._pass_version_check(**constraints) + except AssertionError as e: + msg = ( + "Incompatible {dep} version: {version}\n{plugin_name} " + "requirements:\nmin: {vmin} | max: {vmax} | " + "incompatible: {incompatible}\n".format( + dep=dep, + plugin_name=self.plugin_name, + **constraints, + ) + ) + raise TmuxpPluginException( + msg, + ) from e + + def _pass_version_check( + self, + version: str | Version, + vmin: str, + vmax: str | None, + incompatible: list[t.Any | str], + ) -> bool: + """Provide affirmative if version compatibility is correct.""" + if vmin and version < Version(vmin): + return False + if vmax and version > Version(vmax): + return False + return version not in incompatible + + def before_workspace_builder(self, session: Session) -> None: + """ + Provide a session hook previous to creating the workspace. + + This runs after the session has been created but before any of + the windows/panes/commands are entered. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ + + def on_window_create(self, window: Window) -> None: + """ + Provide a window hook previous to doing anything with a window. + + This runs runs before anything is created in the windows, like panes. + + Parameters + ---------- + window: :class:`libtmux.Window` + window to hook into + """ + + def after_window_finished(self, window: Window) -> None: + """ + Provide a window hook after creating the window. + + This runs after everything has been created in the window, including + the panes and all of the commands for the panes. It also runs after the + ``options_after`` has been applied to the window. + + Parameters + ---------- + window: :class:`libtmux.Window` + window to hook into + """ + + def before_script(self, session: Session) -> None: + """ + Provide a session hook after the workspace has been built. + + This runs after the workspace has been loaded with ``tmuxp load``. It + augments instead of replaces the ``before_script`` section of the + workspace data. + + This hook provides access to the LibTmux.session object for any + behavior that would be used in the ``before_script`` section of the + workspace file that needs access directly to the session object. + This runs after the workspace has been loaded with ``tmuxp load``. + + The hook augments, rather than replaces, the ``before_script`` section + of the workspace. While it is possible to do all of the + ``before_script`` workspace in this function, if a shell script + is currently being used for the workspace, it would be cleaner to + continue using the script in the ``before_section``. + + If changes to the session need to be made prior to anything being + built, please use + :meth:`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder` instead. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ + + def reattach(self, session: Session) -> None: + """ + Provide a session hook before reattaching to the session. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ diff --git a/src/tmuxp/shell.py b/src/tmuxp/shell.py new file mode 100644 index 0000000000..3d56655ba5 --- /dev/null +++ b/src/tmuxp/shell.py @@ -0,0 +1,322 @@ +# mypy: allow-untyped-calls +"""Utility and helper methods for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import TypeAlias + + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + from typing_extensions import NotRequired, TypedDict, Unpack + + CLIShellLiteral: TypeAlias = t.Literal[ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + + class LaunchOptionalImports(TypedDict): + """tmuxp shell optional imports.""" + + server: NotRequired[Server] + session: NotRequired[Session] + window: NotRequired[Window] + pane: NotRequired[Pane] + + class LaunchImports(t.TypedDict): + """tmuxp shell launch import mapping.""" + + libtmux: ModuleType + Server: type[Server] + Session: type[Session] + Window: type[Window] + Pane: type[Pane] + server: Server | None + session: Session | None + window: Window | None + pane: Pane | None + + +def has_ipython() -> bool: + """Return True if ipython is installed.""" + try: + from IPython import start_ipython # NOQA: F401 + except ImportError: + try: + from IPython.Shell import IPShell # NOQA: F401 + except ImportError: + return False + + return True + + +def has_ptpython() -> bool: + """Return True if ptpython is installed.""" + try: + from ptpython.repl import embed, run_config # F401 + except ImportError: + try: + from prompt_toolkit.contrib.repl import embed, run_config # NOQA: F401 + except ImportError: + return False + + return True + + +def has_ptipython() -> bool: + """Return True if ptpython + ipython are both installed.""" + try: + from ptpython.ipython import embed # F401 + from ptpython.repl import run_config # F401 + except ImportError: + try: + from prompt_toolkit.contrib.ipython import embed # NOQA: F401 + from prompt_toolkit.contrib.repl import run_config # NOQA: F401 + except ImportError: + return False + + return True + + +def has_bpython() -> bool: + """Return True if bpython is installed.""" + try: + from bpython import embed # NOQA: F401 + except ImportError: + return False + return True + + +def detect_best_shell() -> CLIShellLiteral: + """Return the best, most feature-rich shell available.""" + if has_ptipython(): + shell: CLIShellLiteral = "ptipython" + elif has_ptpython(): + shell = "ptpython" + elif has_ipython(): + shell = "ipython" + elif has_bpython(): + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell + + +def get_bpython( + options: LaunchOptionalImports, + extra_args: dict[str, t.Any] | None = None, +) -> Callable[[], None]: + """Return bpython shell.""" + if extra_args is None: + extra_args = {} + + from bpython import embed + + def launch_bpython() -> None: + imported_objects = get_launch_args(**options) + kwargs = {} + if extra_args: + kwargs["args"] = extra_args + embed(imported_objects, **kwargs) + + return launch_bpython + + +def get_ipython_arguments() -> list[str]: + """Return ipython shell args via ``IPYTHON_ARGUMENTS`` environment variables.""" + ipython_args = "IPYTHON_ARGUMENTS" + return os.environ.get(ipython_args, "").split() + + +def get_ipython( + options: LaunchOptionalImports, + **extra_args: dict[str, t.Any], +) -> t.Any: + """Return ipython shell.""" + try: + from IPython import start_ipython + + def launch_ipython() -> None: + imported_objects = get_launch_args(**options) + ipython_arguments = extra_args or get_ipython_arguments() + start_ipython(argv=ipython_arguments, user_ns=imported_objects) + + return launch_ipython # NOQA: TRY300 + except ImportError: + # IPython < 0.11 + # Explicitly pass an empty list as arguments, because otherwise + # IPython would use sys.argv from this script. + # Notebook not supported for IPython < 0.11. + from IPython.Shell import IPShell + + def launch_ipython() -> None: + imported_objects = get_launch_args(**options) + shell = IPShell(argv=[], user_ns=imported_objects) + shell.mainloop() + + return launch_ipython + + +def get_ptpython(options: LaunchOptionalImports, vi_mode: bool = False) -> t.Any: + """Return ptpython shell.""" + try: + from ptpython.repl import embed, run_config + except ImportError: + from prompt_toolkit.contrib.repl import embed, run_config + + def launch_ptpython() -> None: + imported_objects = get_launch_args(**options) + history_filename = str(pathlib.Path("~/.ptpython_history").expanduser()) + embed( + globals=imported_objects, + history_filename=history_filename, + vi_mode=vi_mode, + configure=run_config, + ) + + return launch_ptpython + + +def get_ptipython(options: LaunchOptionalImports, vi_mode: bool = False) -> t.Any: + """Based on django-extensions. + + Run renamed to launch, get_imported_objects renamed to get_launch_args + """ + try: + from ptpython.ipython import embed + from ptpython.repl import run_config + except ImportError: + # prompt_toolkit < v0.27 + from prompt_toolkit.contrib.ipython import embed + from prompt_toolkit.contrib.repl import run_config + + def launch_ptipython() -> None: + imported_objects = get_launch_args(**options) + history_filename = str(pathlib.Path("~/.ptpython_history").expanduser()) + embed( + user_ns=imported_objects, + history_filename=history_filename, + vi_mode=vi_mode, + configure=run_config, + ) + + return launch_ptipython + + +def get_launch_args(**kwargs: Unpack[LaunchOptionalImports]) -> LaunchImports: + """Return tmuxp shell launch arguments, counting for overrides.""" + import libtmux + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + return { + "libtmux": libtmux, + "Server": Server, + "Session": Session, + "Window": Window, + "Pane": Pane, + "server": kwargs.get("server"), + "session": kwargs.get("session"), + "window": kwargs.get("window"), + "pane": kwargs.get("pane"), + } + + +def get_code(use_pythonrc: bool, imported_objects: LaunchImports) -> t.Any: + """Launch basic python shell via :mod:`code`.""" + import code + + try: + # Try activating rlcompleter, because it's handy. + import readline + except ImportError: + pass + else: + # We don't have to wrap the following import in a 'try', because + # we already know 'readline' was imported successfully. + import rlcompleter + + readline.set_completer( + rlcompleter.Completer( + imported_objects, # type:ignore + ).complete, + ) + # Enable tab completion on systems using libedit (e.g. macOS). + # These lines are copied from Lib/site.py on Python 3.4. + readline_doc = getattr(readline, "__doc__", "") + if readline_doc is not None and "libedit" in readline_doc: + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab:complete") + + # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system + # conventions and get $PYTHONSTARTUP first then .pythonrc.py. + if use_pythonrc: + PYTHONSTARTUP = os.environ.get("PYTHONSTARTUP") + for pythonrc in { + *([pathlib.Path(PYTHONSTARTUP)] if PYTHONSTARTUP is not None else []), + pathlib.Path("~/.pythonrc.py").expanduser(), + }: + if not pythonrc: + continue + if not pythonrc.is_file(): + continue + with pythonrc.open() as handle: + pythonrc_code = handle.read() + # Match the behavior of the cpython shell where an error in + # PYTHONSTARTUP prints an exception and continues. + exec( + compile(pythonrc_code, pythonrc, "exec"), + imported_objects, # type:ignore + ) + + def launch_code() -> None: + code.interact(local=imported_objects) # type:ignore + + return launch_code + + +def launch( + shell: CLIShellLiteral | None = "best", + use_pythonrc: bool = False, + use_vi_mode: bool = False, + **kwargs: Unpack[LaunchOptionalImports], +) -> None: + """Launch interactive libtmux shell for tmuxp shell.""" + # Also allowing passing shell='code' to force using code.interact + imported_objects = get_launch_args(**kwargs) + + if shell == "best": + shell = detect_best_shell() + + if shell == "ptipython": + launch = get_ptipython(options=kwargs, vi_mode=use_vi_mode) + elif shell == "ptpython": + launch = get_ptpython(options=kwargs, vi_mode=use_vi_mode) + elif shell == "ipython": + launch = get_ipython(options=kwargs) + elif shell == "bpython": + launch = get_bpython(options=kwargs) + else: + launch = get_code(use_pythonrc=use_pythonrc, imported_objects=imported_objects) + + launch() diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py new file mode 100644 index 0000000000..4aa5031239 --- /dev/null +++ b/src/tmuxp/types.py @@ -0,0 +1,21 @@ +"""Internal :term:`type annotations `. + +Notes +----- +:class:`StrPath` and :class:`StrOrBytesPath` is based on `typeshed's`_. + +.. _typeshed's: https://github.com/python/typeshed/blob/9687d5/stdlib/_typeshed/__init__.pyi#L98 +""" # E501 + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from os import PathLike + +StrPath = t.Union[str, "PathLike[str]"] +""":class:`os.PathLike` or :class:`str`""" diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py new file mode 100644 index 0000000000..152b1f6c06 --- /dev/null +++ b/src/tmuxp/util.py @@ -0,0 +1,217 @@ +"""Utility and helper methods for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import shlex +import subprocess +import sys +import typing as t + +from . import exc +from .log import tmuxp_echo + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + +logger = logging.getLogger(__name__) + +PY2 = sys.version_info[0] == 2 + + +def run_before_script( + script_file: str | pathlib.Path, + cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, +) -> int: + """Execute shell script, streaming output to callback or terminal (if TTY). + + Output is buffered and optionally forwarded via the ``on_line`` callback. + """ + script_cmd = shlex.split(str(script_file)) + + try: + proc = subprocess.Popen( + script_cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, # decode to str + errors="backslashreplace", + ) + except FileNotFoundError as e: + raise exc.BeforeLoadScriptNotExists( + e, + os.path.abspath(script_file), # NOQA: PTH100 + ) from e + + out_buffer = [] + err_buffer = [] + + # While process is running, read lines from stdout/stderr + # and write them to this process's stdout/stderr if isatty + is_out_tty = sys.stdout.isatty() + is_err_tty = sys.stderr.isatty() + + # You can do a simple loop reading in real-time: + while True: + # Use .poll() to check if the child has exited + return_code = proc.poll() + + # Read one line from stdout, if available + line_out = proc.stdout.readline() if proc.stdout else "" + + # Read one line from stderr, if available + line_err = proc.stderr.readline() if proc.stderr else "" + + if line_out and line_out.strip(): + out_buffer.append(line_out) + if on_line is not None: + on_line(line_out) + elif is_out_tty: + sys.stdout.write(line_out) + sys.stdout.flush() + + if line_err and line_err.strip(): + err_buffer.append(line_err) + if on_line is not None: + on_line(line_err) + elif is_err_tty: + sys.stderr.write(line_err) + sys.stderr.flush() + + # If no more data from pipes and process ended, break + if not line_out and not line_err and return_code is not None: + break + + # At this point, the process has finished + return_code = proc.wait() + + if return_code != 0: + # Join captured stderr lines for your exception + stderr_str = "".join(err_buffer).strip() + raise exc.BeforeLoadScriptError( + return_code, + os.path.abspath(script_file), # NOQA: PTH100 + stderr_str, + ) + + return return_code + + +def oh_my_zsh_auto_title() -> None: + """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. + + See: https://github.com/robbyrussell/oh-my-zsh/pull/257 + """ + if ( + "SHELL" in os.environ + and "zsh" in os.environ.get("SHELL", "") + and os.path.exists(os.path.expanduser("~/.oh-my-zsh")) # NOQA: PTH110, PTH111 + and ( + "DISABLE_AUTO_TITLE" not in os.environ + or os.environ.get("DISABLE_AUTO_TITLE") == "false" + ) + ): + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" + "Please set:\n\n" + "\texport DISABLE_AUTO_TITLE='true'\n\n" + "in ~/.zshrc or where your zsh profile is stored.\n" + 'Remember the "export" at the beginning!\n\n' + "Then create a new shell or type:\n\n" + "\t$ source ~/.zshrc", + ) + + +def get_current_pane(server: Server) -> Pane | None: + """Return Pane if one found in env.""" + if os.getenv("TMUX_PANE") is not None: + try: + return next(p for p in server.panes if p.pane_id == os.getenv("TMUX_PANE")) + except StopIteration: + pass + return None + + +def get_session( + server: Server, + session_name: str | None = None, + current_pane: Pane | None = None, +) -> Session: + """Get tmux session for server by session name, respects current pane, if passed.""" + try: + if session_name: + session = server.sessions.get(session_name=session_name) + elif current_pane is not None: + session = server.sessions.get(session_id=current_pane.session_id) + else: + current_pane = get_current_pane(server) + if current_pane: + session = server.sessions.get(session_id=current_pane.session_id) + else: + session = server.sessions[0] + + except Exception as e: + if session_name: + raise exc.SessionNotFound(session_name) from e + raise exc.SessionNotFound from e + + assert session is not None + return session + + +def get_window( + session: Session, + window_name: str | None = None, + current_pane: Pane | None = None, +) -> Window: + """Get tmux window for server by window name, respects current pane, if passed.""" + try: + if window_name: + window = session.windows.get(window_name=window_name) + elif current_pane is not None: + window = session.windows.get(window_id=current_pane.window_id) + else: + window = session.windows[0] + except Exception as e: + if window_name: + raise exc.WindowNotFound(window_target=window_name) from e + if current_pane: + raise exc.WindowNotFound(window_target=str(current_pane)) from e + raise exc.WindowNotFound from e + + assert window is not None + return window + + +def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: + """Get tmux pane for server by pane name, respects current pane, if passed.""" + pane = None + try: + if current_pane is not None: + pane = window.panes.get(pane_id=current_pane.pane_id) + else: + pane = window.active_pane + except Exception as e: + logger.debug( + "pane lookup failed", + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e + + if pane is None: + if current_pane: + raise exc.PaneNotFound(str(current_pane)) + raise exc.PaneNotFound + + return pane diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py new file mode 100644 index 0000000000..2b3996b050 --- /dev/null +++ b/src/tmuxp/workspace/__init__.py @@ -0,0 +1,7 @@ +"""tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py new file mode 100644 index 0000000000..469a5308e9 --- /dev/null +++ b/src/tmuxp/workspace/builder/__init__.py @@ -0,0 +1,43 @@ +"""Workspace builders: turn an expanded workspace ``dict`` into a tmux session. + +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the default +implementation. A workspace may select a different builder with the +``workspace_builder`` config key, resolved by +:mod:`tmuxp.workspace.builder.registry`. + +``WorkspaceBuilder`` remains importable here as a backwards-compatible alias of +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. +""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ( + ClassicWorkspaceBuilder, + get_default_columns, + get_default_rows, +) +from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol +from tmuxp.workspace.builder.registry import ( + WORKSPACE_BUILDERS_GROUP, + available_builders, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) + +# Backwards-compatible alias: the classic builder was historically named +# ``WorkspaceBuilder`` and imported from ``tmuxp.workspace.builder``. +WorkspaceBuilder = ClassicWorkspaceBuilder + +__all__ = [ + "WORKSPACE_BUILDERS_GROUP", + "ClassicWorkspaceBuilder", + "WorkspaceBuilder", + "WorkspaceBuilderProtocol", + "available_builders", + "get_default_columns", + "get_default_rows", + "prepended_sys_path", + "resolve_builder_class", + "resolve_builder_paths", +] diff --git a/src/tmuxp/workspace/builder/classic.py b/src/tmuxp/workspace/builder/classic.py new file mode 100644 index 0000000000..c465eb29e4 --- /dev/null +++ b/src/tmuxp/workspace/builder/classic.py @@ -0,0 +1,907 @@ +"""Create a tmux workspace from a workspace :class:`dict`.""" + +from __future__ import annotations + +import logging +import os +import shutil +import time +import typing as t + +from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.window import Window + +from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter +from tmuxp.util import get_current_pane, run_before_script +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + +if t.TYPE_CHECKING: + from collections.abc import Iterator + +logger = logging.getLogger(__name__) + + +def _wait_for_pane_ready( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, +) -> bool: + """Wait for pane shell to draw its prompt. + + Polls the pane's cursor position until it moves from origin (0, 0), + indicating the shell has finished initializing and drawn its prompt. + + Parameters + ---------- + pane : :class:`libtmux.Pane` + pane to wait for + timeout : float + maximum seconds to wait before giving up + interval : float + seconds between polling attempts + + Returns + ------- + bool + True if pane became ready, False on timeout or error + + Examples + -------- + >>> pane = session.active_window.active_pane + + Wait for the shell to be ready: + + >>> _wait_for_pane_ready(pane, timeout=5.0) + True + """ + start = time.monotonic() + while time.monotonic() - start < timeout: + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + exc_info=True, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + if pane.cursor_x != "0" or pane.cursor_y != "0": + logger.debug( + "pane ready, cursor moved from origin", + extra={"tmux_pane": str(pane.pane_id)}, + ) + return True + time.sleep(interval) + logger.debug( + "pane readiness check timed out after %.1f seconds", + timeout, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + + +COLUMNS_FALLBACK = 80 + + +def get_default_columns() -> int: + """Return default session column size use when building new tmux sessions.""" + return int( + os.getenv("TMUXP_DEFAULT_COLUMNS", os.getenv("COLUMNS", COLUMNS_FALLBACK)), + ) + + +ROWS_FALLBACK = int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", 24))) + + +def get_default_rows() -> int: + """Return default session row size use when building new tmux sessions.""" + return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) + + +class ClassicWorkspaceBuilder: + """Load workspace from workspace :class:`dict` object. + + Build tmux workspace from a configuration. Creates and names windows, sets options, + splits windows into panes. + + Examples + -------- + >>> import yaml + + >>> session_config = yaml.load(''' + ... session_name: sample workspace + ... start_directory: '~' + ... windows: + ... - window_name: editor + ... layout: main-vertical + ... panes: + ... - shell_command: + ... - cmd: vim + ... - shell_command: + ... - cmd: echo "hey" + ... + ... - window_name: logging + ... panes: + ... - shell_command: + ... - cmd: tail | echo 'hi' + ... + ... - window_name: test + ... panes: + ... - shell_command: + ... - cmd: htop + ... ''', Loader=yaml.Loader) + + >>> builder = ClassicWorkspaceBuilder(session_config=session_config, server=server) + + **New session:** + + >>> builder.build() + + >>> new_session = builder.session + + >>> new_session.name == 'sample workspace' + True + + >>> len(new_session.windows) + 3 + + >>> sorted([window.name for window in new_session.windows]) + ['editor', 'logging', 'test'] + + **Existing session:** + + >>> len(session.windows) + 1 + + >>> builder.build(session=session) + + _Caveat:_ Preserves old session name: + + >>> session.name == 'sample workspace' + False + + >>> len(session.windows) + 3 + + >>> sorted([window.name for window in session.windows]) + ['editor', 'logging', 'test'] + + **Progress callback:** + + >>> calls: list[str] = [] + >>> progress_cfg = { + ... "session_name": "progress-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=progress_cfg, + ... server=server, + ... on_progress=calls.append, + ... ) + >>> builder.build() + >>> "Workspace built" in calls + True + + **Before-script hook:** + + >>> hook_calls: list[bool] = [] + >>> no_script_cfg = { + ... "session_name": "hook-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=no_script_cfg, + ... server=server, + ... on_before_script=lambda: hook_calls.append(True), + ... ) + >>> builder.build() + >>> hook_calls # no before_script in config, callback not fired + [] + + **Script output hook:** + + >>> script_lines: list[str] = [] + >>> no_script_cfg2 = { + ... "session_name": "script-output-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=no_script_cfg2, + ... server=server, + ... on_script_output=script_lines.append, + ... ) + >>> builder.build() + >>> script_lines # no before_script in config, callback not fired + [] + + **Build events hook:** + + >>> events: list[dict] = [] + >>> event_cfg = { + ... "session_name": "events-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=event_cfg, + ... server=server, + ... on_build_event=events.append, + ... ) + >>> builder.build() + >>> [e["event"] for e in events] + ['session_created', 'window_started', 'pane_creating', + 'window_done', 'workspace_built'] + >>> next(e for e in events if e["event"] == "session_created")["session_pane_total"] + 1 + + **Build events with before_script:** + + ``before_script_started`` fires before the script runs; + ``before_script_done`` fires in ``finally`` (success or failure). + + >>> script_events: list[dict] = [] + >>> script_event_cfg = { + ... "session_name": "script-events-demo", + ... "before_script": "echo hello", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=script_event_cfg, + ... server=server, + ... on_build_event=script_events.append, + ... ) + >>> builder.build() + >>> event_names = [e["event"] for e in script_events] + >>> "before_script_started" in event_names + True + >>> "before_script_done" in event_names + True + >>> bs_start = event_names.index("before_script_started") + >>> bs_done = event_names.index("before_script_done") + >>> win_start = event_names.index("window_started") + >>> bs_start < bs_done < win_start + True + + The normal phase of loading is: + + 1. Load JSON / YAML file via :class:`pathlib.Path`:: + + from tmuxp._internal import config_reader + session_config = config_reader.ConfigReader._load(raw_yaml) + + The reader automatically detects the file type from :attr:`pathlib.suffix`. + + We can also parse raw file:: + + import pathlib + from tmuxp._internal import config_reader + + session_config = config_reader.ConfigReader._from_file( + pathlib.Path('path/to/config.yaml') + ) + + 2. :func:`tmuxp.workspace.loader.expand` session_config inline shorthand:: + + from tmuxp.workspace import loader + session_config = loader.expand(session_config) + + 3. :func:`tmuxp.workspace.loader.trickle` passes down default values from session + -> window -> pane if applicable:: + + session_config = loader.trickle(session_config) + + 4. (You are here) We will create a :class:`libtmux.Session` (a real + ``tmux(1)`` session) and iterate through the list of windows, and + their panes, returning full :class:`libtmux.Window` and + :class:`libtmux.Pane` objects each step of the way:: + + workspace = ClassicWorkspaceBuilder( + session_config=session_config, server=server + ) + + It handles the magic of cases where the user may want to start + a session inside tmux (when `$TMUX` is in the env variables). + """ + + server: Server + _session: Session | None + session_name: str + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + _builder_options: WorkspaceBuilderOptions + _pane_readiness_wait: bool + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Initialize workspace loading. + + Parameters + ---------- + session_config : dict + session config, includes a :class:`list` of ``windows``. + + plugins : list + plugins to be used for this session + + server : :class:`libtmux.Server` + tmux server to build session in + + on_progress : callable, optional + callback for progress updates during building + + on_before_script : callable, optional + called just before ``before_script`` runs; use to clear the terminal + (e.g. stop a spinner) so script output is not interleaved + + on_script_output : callable, optional + called with each output line from ``before_script`` subprocess; when + set, raw TTY tee is suppressed so the caller can route lines to a + live panel instead + + on_build_event : callable, optional + called with a dict event at each structural build milestone (session + created, window started/done, pane creating, workspace built); used + by the CLI to render a live session tree + + Notes + ----- + TODO: Initialize :class:`libtmux.Session` from here, in + ``self.session``. + """ + if plugins is None: + plugins = [] + if not session_config: + raise exc.EmptyWorkspaceException + + # validation.validate_schema(session_config) + + assert isinstance(server, Server) + self.server = server + + self.session_config = session_config + self.plugins = plugins + self.on_progress = on_progress + self.on_before_script = on_before_script + self.on_script_output = on_script_output + self.on_build_event = on_build_event + + # Builder-behavior catalog (e.g. pane readiness). Parsed eagerly so an + # invalid value fails fast; the readiness decision itself needs a live + # session and is resolved in build(). + self._builder_options = WorkspaceBuilderOptions.from_config(session_config) + # Safe default for direct iter_create_panes() use that bypasses build(); + # build() replaces this with the policy-resolved value. + self._pane_readiness_wait = True + + if self.server is not None and self.session_exists( + session_name=self.session_config["session_name"], + ): + try: + session = self.server.sessions.get( + session_name=self.session_config["session_name"], + ) + assert session is not None + self._session = session + except ObjectDoesNotExist: + pass + + @property + def session(self) -> Session: + """Return tmux session using in workspace builder session.""" + if self._session is None: + raise exc.SessionMissingWorkspaceException + return self._session + + def session_exists(self, session_name: str) -> bool: + """Return true if tmux session already exists.""" + assert session_name is not None + assert isinstance(session_name, str) + assert self.server is not None + exists = self.server.has_session(session_name) + if not exists: + return exists + + try: + self.server.sessions.get(session_name=session_name) + except ObjectDoesNotExist: + return False + return True + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build tmux workspace in session. + + Optionally accepts ``session`` to build with only session object. + + Without ``session``, it will use :class:`libtmux.Server` at ``self.server`` + passed in on initialization to create a new Session object. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to build workspace in + append : bool + append windows in current active session + """ + if not session: + if not self.server: + msg = ( + "ClassicWorkspaceBuilder.build requires server to be passed " + "on initialization, or pass in session object to here." + ) + raise exc.TmuxpException( + msg, + ) + new_session_kwargs = {} + if "start_directory" in self.session_config: + new_session_kwargs["start_directory"] = self.session_config[ + "start_directory" + ] + + if os.getenv("TMUXP_DETECT_TERMINAL_SIZE", "1") == "1": + terminal_size = shutil.get_terminal_size( + fallback=(get_default_columns(), get_default_rows()), + ) + new_session_kwargs["x"] = terminal_size.columns + new_session_kwargs["y"] = terminal_size.lines + + session = self.server.new_session( + session_name=self.session_config["session_name"], + **new_session_kwargs, + ) + assert session is not None + + assert self.session_config["session_name"] == session.name + assert len(self.session_config["session_name"]) > 0 + + assert session is not None + assert session.name is not None + + if self.on_progress: + self.on_progress(f"Session created: {session.name}") + + self._session = session + if self.on_build_event: + self.on_build_event( + { + "event": "session_created", + "name": session.name, + "window_total": len(self.session_config["windows"]), + "session_pane_total": sum( + len(w.get("panes", [])) for w in self.session_config["windows"] + ), + } + ) + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") + + assert session.server is not None + + self.server: Server = session.server + assert self.server.sessions is not None + assert self.server.has_session(session.name) + assert session.id + + assert isinstance(session, Session) + + for plugin in self.plugins: + plugin.before_workspace_builder(self.session) + + focus = None + + if "before_script" in self.session_config: + if self.on_before_script: + self.on_before_script() + if self.on_build_event: + self.on_build_event({"event": "before_script_started"}) + try: + cwd = None + + # we want to run the before_script file cwd'd from the + # session start directory, if it exists. + if "start_directory" in self.session_config: + cwd = self.session_config["start_directory"] + _log.debug( + "running before script", + ) + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) + except Exception: + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) + self.session.kill() + raise + finally: + if self.on_build_event: + self.on_build_event({"event": "before_script_done"}) + + if "options" in self.session_config: + for option, value in self.session_config["options"].items(): + self.session.set_option(option, value) + + if "global_options" in self.session_config: + for option, value in self.session_config["global_options"].items(): + self.session.set_option(option, value, global_=True) + + if "environment" in self.session_config: + for option, value in self.session_config["environment"].items(): + self.session.set_environment(option, value) + + # Resolve the pane-readiness decision once, now that the session exists + # and its options (including `default-shell`) have been applied above. + # AUTO waits only for zsh (the shell the prompt-redraw wait was added + # for); ALWAYS/NEVER force the choice. + readiness = self._builder_options.pane_readiness + if readiness is PaneReadiness.ALWAYS: + self._pane_readiness_wait = True + elif readiness is PaneReadiness.NEVER: + self._pane_readiness_wait = False + else: # PaneReadiness.AUTO + self._pane_readiness_wait = shell_is_zsh( + resolve_session_shell(self.session), + ) + _log.debug( + "pane readiness resolved", + extra={ + "tmux_pane_readiness": readiness.value, + "tmux_pane_readiness_wait": self._pane_readiness_wait, + }, + ) + + for window, window_config in self.iter_create_windows(session, append): + assert isinstance(window, Window) + + for plugin in self.plugins: + plugin.on_window_create(window) + + focus_pane = None + for pane, pane_config in self.iter_create_panes(window, window_config): + assert isinstance(pane, Pane) + pane = pane + + if pane_config.get("focus"): + focus_pane = pane + + if window_config.get("focus"): + focus = window + + self.config_after_window(window, window_config) + + for plugin in self.plugins: + plugin.after_window_finished(window) + + if focus_pane: + focus_pane.select() + + if self.on_build_event: + self.on_build_event({"event": "window_done"}) + + if focus: + focus.select() + + if self.on_progress: + self.on_progress("Workspace built") + _log.info("workspace built") + if self.on_build_event: + self.on_build_event({"event": "workspace_built"}) + + def iter_create_windows( + self, + session: Session, + append: bool = False, + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Window` iterating through session config dict. + + Generator yielding :class:`libtmux.Window` by iterating through + ``session_config['windows']``. + + Applies ``window_options`` to window. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to create windows in + append : bool + append windows in current active session + + Returns + ------- + tuple of (:class:`libtmux.Window`, ``window_config``) + Newly created window, and the section from the tmuxp configuration + that was used to create the window. + """ + for window_iterator, window_config in enumerate( + self.session_config["windows"], + start=1, + ): + window_name = window_config.get("window_name", None) + + if self.on_progress: + self.on_progress(f"Creating window: {window_name or window_iterator}") + if self.on_build_event: + self.on_build_event( + { + "event": "window_started", + "name": window_name or str(window_iterator), + "pane_total": len(window_config["panes"]), + } + ) + + is_first_window_pass = self.first_window_pass( + window_iterator, + session, + append, + ) + + w1 = None + if is_first_window_pass: # if first window, use window 1 + w1 = session.active_window + w1.move_window("99") + + start_directory = window_config.get("start_directory", None) + + # If the first pane specifies a start_directory, use that instead. + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] + + window_shell = window_config.get("window_shell", None) + + # If the first pane specifies a shell, use that instead. + try: + if window_config["panes"][0]["shell"] != "": + window_shell = window_config["panes"][0]["shell"] + except (KeyError, IndexError): + pass + + environment = panes[0].get("environment", window_config.get("environment")) + + window = session.new_window( + window_name=window_name, + start_directory=start_directory, + attach=False, # do not move to the new window + window_index=window_config.get("window_index", ""), + window_shell=window_shell, + environment=environment, + ) + assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") + + if is_first_window_pass: # if first window, use window 1 + session.active_window.kill() + + if "options" in window_config and isinstance( + window_config["options"], + dict, + ): + for key, val in window_config["options"].items(): + window.set_option(key, val) + + if window_config.get("focus"): + window.select() + + yield window, window_config + + def iter_create_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Pane` iterating through window config dict. + + Run ``shell_command`` with ``$ tmux send-keys``. + + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window + + Returns + ------- + tuple of (:class:`libtmux.Pane`, ``pane_config``) + Newly created pane, and the section from the tmuxp configuration + that was used to create the pane. + """ + assert isinstance(window, Window) + + pane_base_index = window.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + + pane = None + + for pane_index, pane_config in enumerate( + window_config["panes"], + start=pane_base_index, + ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + + if pane_index == int(pane_base_index): + pane = window.active_pane + else: + + def get_pane_start_directory( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "start_directory" in pane_config: + return pane_config["start_directory"] + if "start_directory" in window_config: + return window_config["start_directory"] + return None + + def get_pane_shell( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "shell" in pane_config: + return pane_config["shell"] + if "window_shell" in window_config: + return window_config["window_shell"] + return None + + environment = pane_config.get( + "environment", + window_config.get("environment"), + ) + + assert pane is not None + + pane = pane.split( + attach=True, + start_directory=get_pane_start_directory( + pane_config=pane_config, + window_config=window_config, + ), + shell=get_pane_shell( + pane_config=pane_config, + window_config=window_config, + ), + environment=environment, + ) + + assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") + + # Skip readiness wait when a custom shell/command launcher is set. + # The shell/window_shell key runs a command (e.g. "top", "sleep 999") + # that replaces the default shell — the pane exits when the command + # exits, so there is no interactive prompt to wait for. The + # pane_readiness policy (resolved in build()) further gates whether + # default-shell panes wait at all. + pane_shell = pane_config.get("shell", window_config.get("window_shell")) + if pane_shell is None and self._pane_readiness_wait: + _wait_for_pane_ready(pane) + + if "layout" in window_config: + window.select_layout(window_config["layout"]) + + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + elif "suppress_history" in window_config: + suppress = window_config["suppress_history"] + else: + suppress = True + + enter = pane_config.get("enter", True) + sleep_before = pane_config.get("sleep_before", None) + sleep_after = pane_config.get("sleep_after", None) + for cmd in pane_config["shell_command"]: + enter = cmd.get("enter", enter) + sleep_before = cmd.get("sleep_before", sleep_before) + sleep_after = cmd.get("sleep_after", sleep_after) + + if sleep_before is not None: + time.sleep(sleep_before) + + pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) + + if sleep_after is not None: + time.sleep(sleep_after) + + if pane_config.get("focus"): + assert pane.pane_id is not None + window.select_pane(pane.pane_id) + + yield pane, pane_config + + def config_after_window( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> None: + """Actions to apply to window after window and pane finished. + + When building a tmux session, sometimes its easier to postpone things + like setting options until after things are already structurally + prepared. + + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window + """ + if "options_after" in window_config and isinstance( + window_config["options_after"], + dict, + ): + for key, val in window_config["options_after"].items(): + window.set_option(key, val) + + def find_current_attached_session(self) -> Session: + """Return current attached session.""" + assert self.server is not None + + current_active_pane = get_current_pane(self.server) + + if current_active_pane is None: + raise exc.ActiveSessionMissingWorkspaceException + + return next( + ( + s + for s in self.server.sessions + if s.session_id == current_active_pane.session_id + ), + ) + + def first_window_pass(self, i: int, session: Session, append: bool) -> bool: + """Return True first window, used when iterating session windows.""" + return len(session.windows) == 1 and i == 1 and not append diff --git a/src/tmuxp/workspace/builder/protocol.py b/src/tmuxp/workspace/builder/protocol.py new file mode 100644 index 0000000000..0557bf7849 --- /dev/null +++ b/src/tmuxp/workspace/builder/protocol.py @@ -0,0 +1,108 @@ +"""Public contract for tmuxp workspace builders. + +A workspace builder turns an expanded workspace ``dict`` into a live tmux +session. :class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the +default implementation; third parties may ship their own and select them with +the ``workspace_builder`` config key (see +:mod:`tmuxp.workspace.builder.registry`). + +This module is intentionally dependency-light (typing only) so builder authors +can import the contract without pulling in tmuxp's resolution machinery. + +The contract is synchronous today. Async builders are an *additive* future +extension: a subclass protocol can declare an ``async`` build entry point and a +capability flag, discovered through the same entry-point group, without +changing this sync surface. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +@t.runtime_checkable +class WorkspaceBuilderProtocol(t.Protocol): + """Contract a builder must satisfy to be driven by ``tmuxp load``. + + A conforming builder is constructed with at least ``session_config`` and + ``server`` (plus the optional ``plugins`` and ``on_*`` callbacks the classic + builder accepts). It exposes :meth:`build`, surfaces the populated tmux + session via :attr:`session`, and honors the plugin/progress integration + points the CLI drives. + + Because the protocol carries data members (``session``, ``plugins``, the + ``on_*`` callbacks), validate *instances* with :func:`isinstance`; + class-level ``issubclass`` checks are not supported for runtime-checkable + protocols with non-method members. + + Examples + -------- + A builder implementing the full contract satisfies the protocol: + + >>> from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + >>> class MiniBuilder: + ... plugins: list = [] + ... on_progress = None + ... on_before_script = None + ... on_script_output = None + ... on_build_event = None + ... def __init__(self, session_config, server, **kwargs): + ... self._session_config = session_config + ... def build(self, session=None, append=False): + ... ... + ... def session_exists(self, session_name): + ... ... + ... def find_current_attached_session(self): + ... ... + ... @property + ... def session(self): + ... ... + >>> builder = MiniBuilder(session_config={}, server=None) + >>> isinstance(builder, WorkspaceBuilderProtocol) + True + + An object missing the contract does not: + + >>> isinstance(object(), WorkspaceBuilderProtocol) + False + """ + + plugins: list[t.Any] + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Construct a builder from an expanded workspace and a tmux server.""" + ... + + @property + def session(self) -> Session: + """Return the tmux session the builder created or populated.""" + ... + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, creating or populating a tmux session.""" + ... + + def session_exists(self, session_name: str) -> bool: + """Return ``True`` if a session with ``session_name`` already exists.""" + ... + + def find_current_attached_session(self) -> Session: + """Return the session currently attached within ``$TMUX``.""" + ... diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py new file mode 100644 index 0000000000..e29a58ec70 --- /dev/null +++ b/src/tmuxp/workspace/builder/registry.py @@ -0,0 +1,326 @@ +"""Resolve and sandbox workspace builders selected by a workspace config. + +A workspace may point tmuxp at a builder other than the classic +:class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` via the +``workspace_builder`` key (a Python dotted path, ``module:attr`` reference, or +an entry-point name in the ``tmuxp.workspace_builders`` group). When the builder +lives outside the active environment, ``workspace_builder_paths`` lists trusted +directories that are temporarily added to ``sys.path`` for the import and build. + +Security note: only literal directories are prepended to ``sys.path``. This +deliberately avoids :func:`site.addsitedir`, which executes ``.pth`` startup +files and is broader than making a module importable. The trust boundary is the +author of the workspace file. +""" + +from __future__ import annotations + +import contextlib +import importlib +import inspect +import os +import pathlib +import sys +import typing as t +from importlib import metadata + +from tmuxp import exc +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder +from tmuxp.workspace.loader import expandshell + +if t.TYPE_CHECKING: + from collections.abc import Iterator + + from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + +WORKSPACE_BUILDERS_GROUP = "tmuxp.workspace_builders" +"""Entry-point group packaged builders register under.""" + + +def resolve_builder_paths( + session_config: dict[str, t.Any], + workspace_file: str | os.PathLike[str] | None = None, +) -> list[pathlib.Path]: + """Resolve and validate trusted ``workspace_builder_paths`` import roots. + + Each entry is shell-expanded (``~`` and ``$VARS``), resolved relative to the + workspace file's directory when not absolute, and required to be an existing + directory. Returns ``[]`` when the key is absent, so existing workspaces add + nothing to ``sys.path``. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + workspace_file : str or os.PathLike, optional + path to the workspace file; its parent anchors relative entries + + Returns + ------- + list of pathlib.Path + + Examples + -------- + Absent key resolves to an empty list (no-op for existing workspaces): + + >>> resolve_builder_paths({}, None) + [] + + An existing directory is resolved: + + >>> root = tmp_path / "roots" + >>> root.mkdir() + >>> resolve_builder_paths({"workspace_builder_paths": [str(root)]}, None) == [ + ... root.resolve() + ... ] + True + + A missing directory is rejected: + + >>> resolve_builder_paths( + ... {"workspace_builder_paths": [str(tmp_path / "missing")]}, None + ... ) + Traceback (most recent call last): + ... + tmuxp.exc.WorkspaceBuilderPathError: ... + """ + raw = session_config.get("workspace_builder_paths") or [] + if isinstance(raw, (str, os.PathLike)): + raw = [raw] + + base = ( + pathlib.Path(workspace_file).parent + if workspace_file is not None + else pathlib.Path.cwd() + ) + + resolved: list[pathlib.Path] = [] + for entry in raw: + if not isinstance(entry, (str, os.PathLike)): + raise exc.WorkspaceBuilderPathError( + str(entry), + reason="entries must be path strings", + ) + candidate = pathlib.Path(expandshell(str(entry))) + if not candidate.is_absolute(): + candidate = base / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + raise exc.WorkspaceBuilderPathError(str(PrivatePath(candidate))) + resolved.append(candidate) + return resolved + + +@contextlib.contextmanager +def prepended_sys_path( + paths: list[pathlib.Path] | None, +) -> Iterator[None]: + """Temporarily prepend directories to ``sys.path``, restoring it on exit. + + Paths are prepended in order (first entry ends up at ``sys.path[0]``). An + empty or falsy ``paths`` is a no-op. Uses only literal directory entries; it + avoids :func:`site.addsitedir` (which runs ``.pth`` startup code). + + Parameters + ---------- + paths : list of pathlib.Path or None + directories to prepend + + Examples + -------- + >>> import sys + >>> ext = tmp_path / "ext" + >>> ext.mkdir() + >>> before = list(sys.path) + >>> with prepended_sys_path([ext]): + ... sys.path[0] == str(ext) + True + >>> sys.path == before + True + """ + if not paths: + yield + return + saved = list(sys.path) + for path in reversed(paths): + sys.path.insert(0, str(path)) + try: + yield + finally: + sys.path[:] = saved + + +def available_builders() -> list[str]: + """Return the names of builders registered via entry points. + + Examples + -------- + >>> isinstance(available_builders(), list) + True + """ + return [ep.name for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP)] + + +def _load_entry_point(name: str) -> t.Any | None: + """Load a builder registered under entry-point ``name``, else ``None``. + + Examples + -------- + >>> _load_entry_point("definitely-not-a-registered-builder") is None + True + """ + for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP): + if ep.name == name: + try: + return ep.load() + except (ImportError, AttributeError) as e: + raise exc.WorkspaceBuilderImportError(name, reason=str(e)) from e + return None + + +def _import_target(target: str) -> t.Any: + """Import an object from a ``module:attr`` or dotted ``module.attr`` path. + + Examples + -------- + >>> _import_target( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ).__name__ + 'ClassicWorkspaceBuilder' + + The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` alias resolves + to the same class: + + >>> _import_target("tmuxp.workspace.builder:WorkspaceBuilder").__name__ + 'ClassicWorkspaceBuilder' + """ + if ":" in target: + module_name, _, attr = target.partition(":") + else: + module_name, _, attr = target.rpartition(".") + if not module_name or not attr: + raise exc.WorkspaceBuilderNotFound(target) + try: + module = importlib.import_module(module_name) + except ImportError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + obj: t.Any = module + try: + for part in attr.split("."): + obj = getattr(obj, part) + except AttributeError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + return obj + + +def _validate_builder(obj: t.Any, target: str) -> None: + """Validate that ``obj`` is a usable workspace builder. + + A class must expose a callable ``build`` method and a constructor accepting + ``session_config``, ``server``, and ``plugins`` (or ``**kwargs``) — the + arguments ``tmuxp load`` always passes. Non-class callables (factories) are + trusted and validated at instantiation. + + Examples + -------- + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> _validate_builder(ClassicWorkspaceBuilder, "classic") + + >>> _validate_builder(object, "object") + Traceback (most recent call last): + ... + tmuxp.exc.InvalidWorkspaceBuilder: 'object' is not a valid workspace builder: ... + """ + if inspect.isclass(obj): + if not callable(getattr(obj, "build", None)): + raise exc.InvalidWorkspaceBuilder( + target, + reason="class has no callable 'build' method", + ) + try: + params = inspect.signature(obj).parameters + except (TypeError, ValueError): + return + has_var_kw = any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + missing = {"session_config", "server", "plugins"} - set(params) + if not has_var_kw and missing: + joined = ", ".join(sorted(missing)) + raise exc.InvalidWorkspaceBuilder( + target, + reason=f"constructor missing parameter(s): {joined}", + ) + elif not callable(obj): + raise exc.InvalidWorkspaceBuilder(target, reason="not a class or callable") + + +def resolve_builder_class( + session_config: dict[str, t.Any], +) -> type[WorkspaceBuilderProtocol]: + """Resolve the workspace builder class selected by ``session_config``. + + Resolution of the ``workspace_builder`` value: + + 1. absent/empty → the classic + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` (no + import, no entry-point scan); + 2. contains ``:`` → a ``module:attr`` object reference; + 3. no ``.`` and no ``:`` → an entry-point name in the + ``tmuxp.workspace_builders`` group; + 4. dotted, no ``:`` → an entry-point name if registered, otherwise a + ``module.attr`` dotted path. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + type + a workspace builder class (or builder factory) satisfying + :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + + Examples + -------- + The default resolves to the classic builder without importing anything: + + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> resolve_builder_class({}) is ClassicWorkspaceBuilder + True + + A dotted ``module:attr`` reference is imported and validated: + + >>> resolve_builder_class( + ... { + ... "workspace_builder": ( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ) + ... } + ... ) is ClassicWorkspaceBuilder + True + """ + target = session_config.get("workspace_builder") + if not target: + return ClassicWorkspaceBuilder + target_str = str(target).strip() + + obj: t.Any + if ":" in target_str: + obj = _import_target(target_str) + elif "." not in target_str: + obj = _load_entry_point(target_str) + if obj is None: + raise exc.WorkspaceBuilderNotFound( + target_str, + available=available_builders(), + ) + else: + obj = _load_entry_point(target_str) + if obj is None: + obj = _import_target(target_str) + + _validate_builder(obj, target_str) + return t.cast("type[WorkspaceBuilderProtocol]", obj) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py new file mode 100644 index 0000000000..4a39082b6d --- /dev/null +++ b/src/tmuxp/workspace/constants.py @@ -0,0 +1,9 @@ +"""Constant variables for tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py new file mode 100644 index 0000000000..2bc7704c28 --- /dev/null +++ b/src/tmuxp/workspace/finders.py @@ -0,0 +1,424 @@ +"""Workspace (configuration file) finders for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +from tmuxp._internal.colors import ColorMode, Colors +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS + +logger = logging.getLogger(__name__) + +#: Local workspace file names (dotfiles in project directories) +LOCAL_WORKSPACE_FILES = [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from tmuxp.types import StrPath + + ValidExtensions: TypeAlias = t.Literal[".yml", ".yaml", ".json"] + + +def is_workspace_file( + filename: str, + extensions: ValidExtensions | list[ValidExtensions] | None = None, +) -> bool: + """ + Return True if file has a valid workspace file type. + + Parameters + ---------- + filename : str + filename to check (e.g. ``mysession.json``). + extensions : str or list + filetypes to check (e.g. ``['.yaml', '.json']``). + + Returns + ------- + bool + """ + if extensions is None: + extensions = [".yml", ".yaml", ".json"] + extensions = [extensions] if isinstance(extensions, str) else extensions + return any(filename.endswith(e) for e in extensions) + + +def in_dir( + workspace_dir: pathlib.Path | str | None = None, + extensions: list[ValidExtensions] | None = None, +) -> list[str]: + """ + Return a list of workspace_files in ``workspace_dir``. + + Parameters + ---------- + workspace_dir : str + directory to search + extensions : list + filetypes to check (e.g. ``['.yaml', '.json']``). + + Returns + ------- + list + """ + if workspace_dir is None: + workspace_dir = os.path.expanduser("~/.tmuxp") + + if extensions is None: + extensions = [".yml", ".yaml", ".json"] + + return [ + filename + for filename in os.listdir(workspace_dir) + if is_workspace_file(filename, extensions) and not filename.startswith(".") + ] + + +def in_cwd() -> list[str]: + """ + Return list of workspace_files in current working directory. + + If filename is ``.tmuxp.py``, ``.tmuxp.json``, ``.tmuxp.yaml``. + + Returns + ------- + list + workspace_files in current working directory + + Examples + -------- + >>> sorted(in_cwd()) + ['.tmuxp.json', '.tmuxp.yaml'] + """ + return [ + filename + for filename in os.listdir(os.getcwd()) + if filename.startswith(".tmuxp") and is_workspace_file(filename) + ] + + +def find_local_workspace_files( + start_dir: pathlib.Path | str | None = None, + *, + stop_at_home: bool = True, +) -> list[pathlib.Path]: + """Find .tmuxp.* files by traversing upward from start directory. + + Searches the start directory and all parent directories up to (but not past): + - User home directory (when stop_at_home=True) + - Filesystem root + + Parameters + ---------- + start_dir : pathlib.Path | str | None + Directory to start searching from. Defaults to current working directory. + stop_at_home : bool + If True, stops traversal at user home directory. Default True. + + Returns + ------- + list[pathlib.Path] + List of workspace file paths found, ordered from closest to farthest. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... home = pathlib.Path(tmpdir) + ... project = home / "project" + ... project.mkdir() + ... _ = (project / ".tmuxp.yaml").write_text("session_name: test") + ... # Would find .tmuxp.yaml in project dir + ... len(find_local_workspace_files(project, stop_at_home=False)) >= 0 + True + """ + if start_dir is None: + start_dir = os.getcwd() + + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + + current = pathlib.Path(start_dir).resolve() + home = pathlib.Path.home().resolve() + found: list[pathlib.Path] = [] + + while True: + # Check for local workspace files in current directory + for filename in LOCAL_WORKSPACE_FILES: + candidate = current / filename + if candidate.is_file(): + found.append(candidate) + break # Only one per directory (first match wins: .yaml > .yml > .json) + + # Stop conditions + parent = current.parent + if parent == current: # Reached filesystem root + break + if stop_at_home and current == home: + break + + current = parent + + return found + + +def get_workspace_dir() -> str: + """ + Return tmuxp workspace directory. + + ``TMUXP_CONFIGDIR`` environmental variable has precedence if set. We also + evaluate XDG default directory from XDG_CONFIG_HOME environmental variable + if set or its default. Then the old default ~/.tmuxp is returned for + compatibility. + + Returns + ------- + str : + absolute path to tmuxp config directory + """ + paths = [] + if "TMUXP_CONFIGDIR" in os.environ: + paths.append(os.environ["TMUXP_CONFIGDIR"]) + if "XDG_CONFIG_HOME" in os.environ: + paths.append(os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp")) + else: + paths.append("~/.config/tmuxp/") + paths.append("~/.tmuxp") + + for path in paths: + path = os.path.expanduser(path) + if os.path.isdir(path): + return path + # Return last path as default if none of the previous ones matched + return path + + +def get_workspace_dir_candidates() -> list[dict[str, t.Any]]: + """Return all candidate workspace directories with existence status. + + Returns a list of all directories that tmuxp checks for workspaces, + in priority order, with metadata about each. + + The priority order is: + 1. ``TMUXP_CONFIGDIR`` environment variable (if set) + 2. ``XDG_CONFIG_HOME/tmuxp`` (if XDG_CONFIG_HOME set) OR ``~/.config/tmuxp/`` + 3. ``~/.tmuxp`` (legacy default) + + Returns + ------- + list[dict[str, Any]] + List of dicts with: + - path: str (privacy-masked via PrivatePath) + - source: str (e.g., "$TMUXP_CONFIGDIR", "$XDG_CONFIG_HOME/tmuxp", "Legacy") + - exists: bool + - workspace_count: int (0 if not exists) + - active: bool (True if this is the directory get_workspace_dir() returns) + + Examples + -------- + >>> candidates = get_workspace_dir_candidates() + >>> isinstance(candidates, list) + True + >>> all('path' in c and 'exists' in c for c in candidates) + True + """ + # Build list of candidate paths with sources (same logic as get_workspace_dir) + # Each entry is (raw_path, source_label) + path_sources: list[tuple[str, str]] = [] + if "TMUXP_CONFIGDIR" in os.environ: + path_sources.append((os.environ["TMUXP_CONFIGDIR"], "$TMUXP_CONFIGDIR")) + if "XDG_CONFIG_HOME" in os.environ: + path_sources.append( + ( + os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp"), + "$XDG_CONFIG_HOME/tmuxp", + ) + ) + else: + path_sources.append(("~/.config/tmuxp/", "XDG default")) + path_sources.append(("~/.tmuxp", "Legacy")) + + # Get the active directory for comparison + active_dir = get_workspace_dir() + + candidates: list[dict[str, t.Any]] = [] + for raw_path, source in path_sources: + expanded = os.path.expanduser(raw_path) + exists = os.path.isdir(expanded) + + # Count workspace files if directory exists + workspace_count = 0 + if exists: + workspace_count = len( + [ + f + for f in os.listdir(expanded) + if not f.startswith(".") + and os.path.splitext(f)[1].lower() + in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + ) + + candidates.append( + { + "path": str(PrivatePath(expanded)), + "source": source, + "exists": exists, + "workspace_count": workspace_count, + "active": expanded == active_dir, + } + ) + + return candidates + + +def find_workspace_file( + workspace_file: StrPath, + workspace_dir: StrPath | None = None, +) -> str: + """ + Return the real config path or raise an exception. + + If workspace file is directory, scan for .tmuxp.{yaml,yml,json} in directory. If + one or more found, it will warn and pick the first. + + If workspace file is ".", "./" or None, it will scan current directory. + + If workspace file is has no path and only a filename, e.g. "my_workspace.yaml" it + will search workspace dir. + + If workspace file has no path and no extension, e.g. "my_workspace", it will scan + for file name with yaml, yml and json. If multiple exist, it will warn and pick the + first. + + Parameters + ---------- + workspace_file : str + Workspace file, valid examples: + + - a file name, my_workspace.yaml + - relative path, ../my_workspace.yaml or ../project + - a period, . + + Returns + ------- + str + Resolved absolute path to workspace file. + + Raises + ------ + FileNotFoundError + If workspace file cannot be found. + """ + if not workspace_dir: + workspace_dir = get_workspace_dir() + path = os.path + exists, join, isabs = path.exists, path.join, path.isabs + dirname, normpath, splitext = path.dirname, path.normpath, path.splitext + cwd = os.getcwd() + is_name = False + file_error = None + + workspace_file = os.path.expanduser(workspace_file) + # if purename, resolve to confg dir + if is_pure_name(workspace_file): + is_name = True + elif ( + not isabs(workspace_file) + or len(dirname(workspace_file)) > 1 + or workspace_file in {".", "", "./"} + ): # if relative, fill in full path + workspace_file = normpath(join(cwd, workspace_file)) + + # no extension, scan + if path.isdir(workspace_file) or not splitext(workspace_file)[1]: + if is_name: + candidates = [ + x + for x in [ + f"{join(workspace_dir, workspace_file)}{ext}" + for ext in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + if exists(x) + ] + if not candidates: + file_error = ( + "workspace-file not found " + f"in workspace dir (yaml/yml/json) {workspace_dir} for name" + ) + else: + candidates = [ + x + for x in [ + join(workspace_file, ext) + for ext in [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + ] + if exists(x) + ] + + if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) + colors = Colors(ColorMode.AUTO) + tmuxp_echo( + colors.error( + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) + ) + tmuxp_echo( + "This is undefined behavior, use only one. " + "Use file names e.g. myproject.json, coolproject.yaml. " + "You can load them by filename.", + ) + elif not candidates: + file_error = "No tmuxp files found in directory" + if candidates: + workspace_file = candidates[0] + elif not exists(workspace_file): + file_error = "file not found" + + if file_error: + raise FileNotFoundError(file_error, workspace_file) + + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) + return workspace_file + + +def is_pure_name(path: str) -> bool: + """ + Return True if path is a name and not a file path. + + Parameters + ---------- + path : str + Path (can be absolute, relative, etc.) + + Returns + ------- + bool + True if path is a name of workspace in workspace dir, not file path. + """ + return ( + not os.path.isabs(path) + and len(os.path.dirname(path)) == 0 + and not os.path.splitext(path)[1] + and path not in {".", ""} + ) diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py new file mode 100644 index 0000000000..a1df02c1dc --- /dev/null +++ b/src/tmuxp/workspace/freezer.py @@ -0,0 +1,137 @@ +"""Tmux session freezing functionality for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + + +def inline(workspace_dict: dict[str, t.Any]) -> t.Any: + """Return workspace with inlined shorthands. + + Opposite of :func:`~tmuxp.workspace.loader.expand`. + + Parameters + ---------- + workspace_dict : dict + + Returns + ------- + dict + workspace with shorthands inlined. + """ + if ( + "shell_command" in workspace_dict + and isinstance(workspace_dict["shell_command"], list) + and len(workspace_dict["shell_command"]) == 1 + ): + workspace_dict["shell_command"] = workspace_dict["shell_command"][0] + + if len(workspace_dict.keys()) == 1: + return workspace_dict["shell_command"] + if ( + "shell_command_before" in workspace_dict + and isinstance(workspace_dict["shell_command_before"], list) + and len(workspace_dict["shell_command_before"]) == 1 + ): + workspace_dict["shell_command_before"] = workspace_dict["shell_command_before"][ + 0 + ] + + # recurse into window and pane workspace items + if "windows" in workspace_dict: + workspace_dict["windows"] = [ + inline(window) for window in workspace_dict["windows"] + ] + if "panes" in workspace_dict: + workspace_dict["panes"] = [inline(pane) for pane in workspace_dict["panes"]] + + return workspace_dict + + +def freeze(session: Session) -> dict[str, t.Any]: + """Freeze live tmux session into a tmuxp workspace. + + Parameters + ---------- + session : :class:`libtmux.Session` + session object + + Returns + ------- + dict + tmuxp compatible workspace + """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + + session_config: dict[str, t.Any] = { + "session_name": session.session_name, + "windows": [], + } + + for window in session.windows: + window_config: dict[str, t.Any] = { + "options": window.show_options(), + "window_name": window.name, + "layout": window.window_layout, + "panes": [], + } + + if getattr(window, "window_active", "0") == "1": + window_config["focus"] = "true" + + # If all panes have same path, set 'start_directory' instead + # of using 'cd' shell commands. + def pane_has_same_path(window: Window, pane: Pane) -> bool: + return window.panes[0].pane_current_path == pane.pane_current_path + + if all(pane_has_same_path(window=window, pane=pane) for pane in window.panes): + window_config["start_directory"] = window.panes[0].pane_current_path + + for pane in window.panes: + pane_config: str | dict[str, t.Any] = {"shell_command": []} + assert isinstance(pane_config, dict) + + if "start_directory" not in window_config and pane.pane_current_path: + pane_config["shell_command"].append("cd " + pane.pane_current_path) + + if getattr(pane, "pane_active", "0") == "1": + pane_config["focus"] = "true" + + current_cmd = pane.pane_current_command + + def filter_interpreters_and_shells(current_cmd: str | None) -> bool: + return current_cmd is not None and ( + current_cmd.startswith("-") + or any( + current_cmd.endswith(cmd) for cmd in ["python", "ruby", "node"] + ) + ) + + if filter_interpreters_and_shells(current_cmd=current_cmd): + current_cmd = None + + if current_cmd: + pane_config["shell_command"].append(current_cmd) + elif not len(pane_config["shell_command"]): + pane_config = "pane" + + window_config["panes"].append(pane_config) + + session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) + + return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py new file mode 100644 index 0000000000..3505981ec4 --- /dev/null +++ b/src/tmuxp/workspace/importers.py @@ -0,0 +1,187 @@ +"""Configuration import adapters to load teamocil, tmuxinator, etc. in tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + + +def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Return tmuxp workspace from a `tmuxinator`__ YAML workspace. + + __ https://github.com/aziz/tmuxinator + + Parameters + ---------- + workspace_dict : dict + python dict for tmuxp workspace. + + Returns + ------- + dict + """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + + tmuxp_workspace: dict[str, t.Any] = {} + + if "project_name" in workspace_dict: + tmuxp_workspace["session_name"] = workspace_dict.pop("project_name") + elif "name" in workspace_dict: + tmuxp_workspace["session_name"] = workspace_dict.pop("name") + else: + tmuxp_workspace["session_name"] = None + + if "project_root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict.pop("project_root") + 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() + ) + + if "socket_name" in workspace_dict: + tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] + + tmuxp_workspace["windows"] = [] + + 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 "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"]), + ) + + for window_dict in workspace_dict["windows"]: + for k, v in window_dict.items(): + window_dict = {"window_name": k} + + if isinstance(v, str) or v is None: + window_dict["panes"] = [v] + tmuxp_workspace["windows"].append(window_dict) + continue + if isinstance(v, list): + window_dict["panes"] = v + tmuxp_workspace["windows"].append(window_dict) + continue + + if "pre" in v: + window_dict["shell_command_before"] = v["pre"] + if "panes" in v: + window_dict["panes"] = v["panes"] + if "root" in v: + window_dict["start_directory"] = v["root"] + + if "layout" in v: + window_dict["layout"] = v["layout"] + tmuxp_workspace["windows"].append(window_dict) + return tmuxp_workspace + + +def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Return tmuxp workspace from a `teamocil`__ YAML workspace. + + __ https://github.com/remiprev/teamocil + + Parameters + ---------- + workspace_dict : dict + python dict for tmuxp workspace + + Notes + ----- + Todos: + + - change 'root' to a cd or start_directory + - width in pane -> main-pain-width + - with_env_var + - clear + - cmd_separator + """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + + tmuxp_workspace: dict[str, t.Any] = {} + + if "session" in workspace_dict: + workspace_dict = workspace_dict["session"] + + tmuxp_workspace["session_name"] = workspace_dict.get("name", None) + + if "root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict.pop("root") + + tmuxp_workspace["windows"] = [] + + for w in workspace_dict["windows"]: + window_dict = {"window_name": w["name"]} + + if "clear" in w: + window_dict["clear"] = w["clear"] + + if "filters" in w: + if "before" in w["filters"]: + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + for _b in w["filters"]["after"]: + window_dict["shell_command_after"] = w["filters"]["after"] + + if "root" in w: + window_dict["start_directory"] = w.pop("root") + + if "splits" in w: + w["panes"] = w.pop("splits") + + if "panes" in w: + for p in w["panes"]: + if "cmd" in p: + p["shell_command"] = p.pop("cmd") + if "width" in p: + # TODO support for height/width + p.pop("width") + window_dict["panes"] = w["panes"] + + if "layout" in w: + window_dict["layout"] = w["layout"] + tmuxp_workspace["windows"].append(window_dict) + + return tmuxp_workspace diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py new file mode 100644 index 0000000000..b022a4da21 --- /dev/null +++ b/src/tmuxp/workspace/loader.py @@ -0,0 +1,275 @@ +"""Workspace hydration and loading for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +logger = logging.getLogger(__name__) + + +def expandshell(value: str) -> str: + """Resolve shell variables based on user's ``$HOME`` and ``env``. + + :py:func:`os.path.expanduser` and :py:func:`os.path.expandvars`. + + Parameters + ---------- + value : str + value to expand + + Returns + ------- + str + value with shell variables expanded + """ + return os.path.expandvars(os.path.expanduser(value)) # NOQA: PTH111 + + +def expand_cmd(p: dict[str, t.Any]) -> dict[str, t.Any]: + """Resolve shell variables and expand shorthands in a tmuxp config mapping.""" + if isinstance(p, str): + p = {"shell_command": [p]} + elif isinstance(p, list): + p = {"shell_command": p} + elif not p: + p = {"shell_command": []} + + assert isinstance(p, dict) + if "shell_command" in p: + cmds = p["shell_command"] + + if isinstance(p["shell_command"], str): + cmds = [cmds] + + if not cmds or any(a == cmds for a in [None, "blank", "pane"]): + cmds = [] + + if ( + isinstance(cmds, list) + and len(cmds) == 1 + and any(a in cmds for a in [None, "blank", "pane"]) + ): + cmds = [] + + for cmd_idx, cmd in enumerate(cmds): + if isinstance(cmd, str): + cmds[cmd_idx] = {"cmd": cmd} + cmds[cmd_idx]["cmd"] = expandshell(cmds[cmd_idx]["cmd"]) + + p["shell_command"] = cmds + else: + p["shell_command"] = [] + return p + + +def expand( + workspace_dict: dict[str, t.Any], + cwd: pathlib.Path | str | None = None, + parent: t.Any | None = None, +) -> dict[str, t.Any]: + """Resolve workspace variables and expand shorthand style / inline properties. + + This is necessary to keep the code in + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` clean and + also allow for neat, short-hand "sugarified" syntax. + + As a simple example, internally, tmuxp expects that workspace options + like ``shell_command`` are a list (array):: + + 'shell_command': ['htop'] + + tmuxp workspace allow for it to be simply a string:: + + 'shell_command': 'htop' + + ConfigReader will load JSON/YAML files into python dicts for you. + + Parameters + ---------- + workspace_dict : dict + the tmuxp workspace for the session + cwd : str + directory to expand relative paths against. should be the dir of the + workspace directory. + parent : str + (used on recursive entries) start_directory of parent window or session + object. + + Returns + ------- + dict + """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + + # Note: cli.py will expand workspaces relative to project's workspace directory + # for the first cwd argument. + cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) + + if "session_name" in workspace_dict: + workspace_dict["session_name"] = expandshell(workspace_dict["session_name"]) + if "window_name" in workspace_dict: + workspace_dict["window_name"] = expandshell(workspace_dict["window_name"]) + if "environment" in workspace_dict: + for key in workspace_dict["environment"]: + val = workspace_dict["environment"][key] + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["environment"][key] = val + if "global_options" in workspace_dict: + for key in workspace_dict["global_options"]: + val = workspace_dict["global_options"][key] + if isinstance(val, str): + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["global_options"][key] = val + if "options" in workspace_dict: + for key in workspace_dict["options"]: + val = workspace_dict["options"][key] + if isinstance(val, str): + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["options"][key] = val + + # Any workspace section, session, window, pane that can contain the + # 'shell_command' value + if "start_directory" in workspace_dict: + workspace_dict["start_directory"] = expandshell( + workspace_dict["start_directory"], + ) + start_path = workspace_dict["start_directory"] + if any(start_path.startswith(a) for a in [".", "./"]): + # if window has a session, or pane has a window with a + # start_directory of . or ./, make sure the start_directory can be + # relative to the parent. + # + # This is for the case where you may be loading a workspace from + # outside your shell current directory. + if parent: + cwd = pathlib.Path(parent["start_directory"]) + + start_path = str((cwd / start_path).resolve(strict=False)) + + workspace_dict["start_directory"] = start_path + + if "before_script" in workspace_dict: + workspace_dict["before_script"] = expandshell(workspace_dict["before_script"]) + if any(workspace_dict["before_script"].startswith(a) for a in [".", "./"]): + workspace_dict["before_script"] = str(cwd / workspace_dict["before_script"]) + + if "shell_command" in workspace_dict and isinstance( + workspace_dict["shell_command"], + str, + ): + workspace_dict["shell_command"] = [workspace_dict["shell_command"]] + + if "shell_command_before" in workspace_dict: + shell_command_before = workspace_dict["shell_command_before"] + + workspace_dict["shell_command_before"] = expand_cmd(shell_command_before) + + # recurse into window and pane workspace items + if "windows" in workspace_dict: + workspace_dict["windows"] = [ + expand(window, parent=workspace_dict) + for window in workspace_dict["windows"] + ] + elif "panes" in workspace_dict: + pane_dicts = workspace_dict["panes"] + for pane_idx, pane_dict in enumerate(pane_dicts): + pane_dicts[pane_idx] = {} + pane_dicts[pane_idx].update(expand_cmd(pane_dict)) + workspace_dict["panes"] = [ + expand(pane, parent=workspace_dict) for pane in pane_dicts + ] + + return workspace_dict + + +def trickle(workspace_dict: dict[str, t.Any]) -> 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 + :func:`~tmuxp.workspace.loader.expand`. + + tmuxp allows certain commands to be default at the session, window + level. shell_command_before trickles down and prepends the + ``shell_command`` for the pane. + + Parameters + ---------- + workspace_dict : dict + the tmuxp workspace. + + Returns + ------- + dict + """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + + # prepends a pane's ``shell_command`` list with the window and sessions' + # ``shell_command_before``. + + session_start_directory = workspace_dict.get("start_directory") + + suppress_history = workspace_dict.get("suppress_history") + + for window_dict in workspace_dict["windows"]: + # Prepend start_directory to relative window commands + if session_start_directory: + session_start_directory = session_start_directory + if "start_directory" not in window_dict: + window_dict["start_directory"] = session_start_directory + elif not any( + window_dict["start_directory"].startswith(a) for a in ["~", "/"] + ): + window_start_path = ( + pathlib.Path(session_start_directory) + / window_dict["start_directory"] + ) + window_dict["start_directory"] = str(window_start_path) + + # We only need to trickle to the window, workspace builder checks wconf + if suppress_history is not None and "suppress_history" not in window_dict: + window_dict["suppress_history"] = suppress_history + + # If panes were NOT specified for a window, assume that a single pane + # with no shell commands is desired + if "panes" not in window_dict: + window_dict["panes"] = [{"shell_command": []}] + + 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"], + ) + + if "shell_command" in pane_dict: + commands_before.extend(pane_dict["shell_command"]) + + window_dict["panes"][pane_idx]["shell_command"] = commands_before + # pane_dict['shell_command'] = commands_before + + return workspace_dict diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py new file mode 100644 index 0000000000..37fa90244f --- /dev/null +++ b/src/tmuxp/workspace/options.py @@ -0,0 +1,231 @@ +"""Behavior options for tmuxp workspace builders. + +The ``workspace_builder_options`` config catalog holds settings that tune how a +workspace builder runs, independent of *which* builder is selected. It is a +sibling to the tmux ``options`` / ``global_options`` / ``environment`` catalogs +and is the home for builder-behavior knobs (today: pane readiness; later: +parallel/async builder settings). + +Example +------- +.. code-block:: yaml + + workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +""" + +from __future__ import annotations + +import dataclasses +import enum +import os +import typing as t + +from tmuxp import exc + +_AUTO_ALIASES = frozenset({"auto"}) +_ALWAYS_ALIASES = frozenset({"always", "true", "on", "yes", "1"}) +_NEVER_ALIASES = frozenset({"never", "false", "off", "no", "0"}) + + +class PaneReadiness(enum.Enum): + """Policy for whether the builder waits for a pane's shell prompt. + + tmuxp waits for each default-shell pane to draw its prompt before + dispatching layout and commands, which avoids a zsh prompt-redraw artifact + (see :func:`tmuxp.workspace.builder.classic._wait_for_pane_ready`). The wait + is only needed for zsh, so the default + :attr:`~tmuxp.workspace.options.PaneReadiness.AUTO` policy waits only when + the session's interactive shell is zsh. + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + @classmethod + def from_config(cls, value: t.Any) -> PaneReadiness: + """Parse a ``pane_readiness`` config value into a policy. + + Accepts the canonical ``auto`` / ``always`` / ``never`` strings, plus + the truthy/falsy aliases users expect from boolean-like config keys. + + Parameters + ---------- + value : Any + value from ``workspace_builder_options.pane_readiness``; ``None`` + (key absent) resolves to + :attr:`~tmuxp.workspace.options.PaneReadiness.AUTO` + + Returns + ------- + PaneReadiness + + Examples + -------- + >>> PaneReadiness.from_config(None) is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config("auto") is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config(True) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config("on") is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(1) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(False) is PaneReadiness.NEVER + True + >>> PaneReadiness.from_config("never") is PaneReadiness.NEVER + True + + Unknown values raise an actionable error: + + >>> PaneReadiness.from_config("sometimes") + Traceback (most recent call last): + ... + ValueError: invalid pane_readiness value: 'sometimes'; expected one of: + auto, always/true/on/yes/1, never/false/off/no/0 + """ + if value is None: + return cls.AUTO + if isinstance(value, cls): + return value + if isinstance(value, bool): + return cls.ALWAYS if value else cls.NEVER + normalized = str(value).strip().lower() + if normalized in _AUTO_ALIASES: + return cls.AUTO + if normalized in _ALWAYS_ALIASES: + return cls.ALWAYS + if normalized in _NEVER_ALIASES: + return cls.NEVER + msg = ( + f"invalid pane_readiness value: {value!r}; expected one of: " + "auto, always/true/on/yes/1, never/false/off/no/0" + ) + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class WorkspaceBuilderOptions: + """Parsed ``workspace_builder_options`` catalog. + + An absent ``workspace_builder_options`` catalog uses the defaults, whose + :attr:`PaneReadiness.AUTO` policy waits for a pane's prompt only when the + session shell is zsh: zsh workspaces build as before, while bash, sh, and + other shells skip the wait. Set ``pane_readiness: always`` to restore the + previous wait-everywhere behavior. + """ + + pane_readiness: PaneReadiness = PaneReadiness.AUTO + """pane-prompt wait policy; defaults to :attr:`PaneReadiness.AUTO`""" + + @classmethod + def from_config(cls, session_config: dict[str, t.Any]) -> WorkspaceBuilderOptions: + """Build options from a full workspace ``session_config`` dict. + + Reads the optional ``workspace_builder_options`` catalog; an absent or + empty catalog yields the defaults (see the class docstring for how the + default :attr:`PaneReadiness.AUTO` policy affects non-zsh shells). + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + WorkspaceBuilderOptions + + Examples + -------- + >>> WorkspaceBuilderOptions.from_config({}).pane_readiness + + + >>> cfg = {"workspace_builder_options": {"pane_readiness": "always"}} + >>> WorkspaceBuilderOptions.from_config(cfg).pane_readiness + + """ + catalog = session_config.get("workspace_builder_options") or {} + if not isinstance(catalog, dict): + msg = f"must be a mapping, got {type(catalog).__name__}" + raise exc.InvalidWorkspaceBuilderOption(msg) + try: + pane_readiness = PaneReadiness.from_config(catalog.get("pane_readiness")) + except ValueError as e: + raise exc.InvalidWorkspaceBuilderOption(str(e)) from e + return cls(pane_readiness=pane_readiness) + + +def shell_is_zsh(shell: str | None) -> bool: + """Return ``True`` when ``shell`` names the zsh shell. + + Parameters + ---------- + shell : str or None + a shell path or name (e.g. ``/usr/bin/zsh``) + + Returns + ------- + bool + + Examples + -------- + >>> shell_is_zsh("/usr/bin/zsh") + True + >>> shell_is_zsh("/bin/bash") + False + >>> shell_is_zsh(None) + False + """ + return "zsh" in (shell or "") + + +def resolve_session_shell( + session: t.Any, + env: t.Mapping[str, str] | None = None, +) -> str: + """Resolve the effective interactive shell for a tmux session. + + Prefers tmux's ``default-shell`` option (which reflects a workspace's + ``options.default-shell`` once applied, otherwise tmux's global default), + and falls back to the ``SHELL`` environment variable. + + Parameters + ---------- + session : :class:`libtmux.Session` + live session exposing ``show_option("default-shell")`` + env : Mapping, optional + environment mapping for the ``SHELL`` fallback; defaults to + :data:`os.environ` + + Returns + ------- + str + resolved shell path/name, or ``""`` when undeterminable + + Examples + -------- + >>> class FakeSession: + ... def __init__(self, shell): + ... self._shell = shell + ... def show_option(self, name, **kwargs): + ... return self._shell + + The tmux ``default-shell`` wins when set: + + >>> resolve_session_shell(FakeSession("/usr/bin/zsh"), env={}) + '/usr/bin/zsh' + + The ``SHELL`` env var is the fallback: + + >>> resolve_session_shell(FakeSession(None), env={"SHELL": "/bin/bash"}) + '/bin/bash' + """ + # include_inherited resolves a globally-set default-shell (e.g. + # ``set -g default-shell`` in tmux.conf) that the session inherits rather + # than overrides; without it tmux returns None and we'd fall back to $SHELL. + default_shell = session.show_option("default-shell", include_inherited=True) + environ = os.environ if env is None else env + env_shell = environ.get("SHELL", "") + return str(default_shell or env_shell or "") diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py new file mode 100644 index 0000000000..e7fe4da743 --- /dev/null +++ b/src/tmuxp/workspace/validation.py @@ -0,0 +1,99 @@ +"""Validation errors for tmuxp configuration files.""" + +from __future__ import annotations + +import logging +import typing as t + +from tmuxp import exc + +logger = logging.getLogger(__name__) + + +class SchemaValidationError(exc.WorkspaceError): + """Tmuxp configuration validation base error.""" + + +class SessionNameMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for session name missing.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace requires "session_name"', + *args, + **kwargs, + ) + + +class WindowListMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for window list missing.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace requires list of "windows"', + *args, + **kwargs, + ) + + +class WindowNameMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for missing window_name.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace window is missing "window_name"', + *args, + **kwargs, + ) + + +class InvalidPluginsValidationError(SchemaValidationError): + """Tmuxp configuration error for invalid plugins.""" + + def __init__(self, plugins: t.Any, *args: object, **kwargs: object) -> None: + return super().__init__( + '"plugins" only supports list type. ' + f" Received {type(plugins)}, " + f"value: {plugins}", + *args, + **kwargs, + ) + + +def validate_schema(workspace_dict: t.Any) -> bool: + """ + Return True if workspace schema is correct. + + Parameters + ---------- + workspace_dict : dict + tmuxp workspace data + + Returns + ------- + bool + """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + + # verify session_name + if "session_name" not in workspace_dict: + raise SessionNameMissingValidationError + + if "windows" not in workspace_dict: + raise WindowListMissingValidationError + + for window in workspace_dict["windows"]: + if "window_name" not in window: + raise WindowNameMissingValidationError + + if "plugins" in workspace_dict and not isinstance(workspace_dict["plugins"], list): + raise InvalidPluginsValidationError(plugins=workspace_dict.get("plugins")) + + return True diff --git a/tests/__init__.py b/tests/__init__.py index 9216f1bba1..6940f22868 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,15 +1 @@ -# -*- coding: utf-8 -*- -"""Tests for tmuxp. - -tmuxp.tests -~~~~~~~~~~~ - -""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import os - -current_dir = os.path.abspath(os.path.dirname(__file__)) -example_dir = os.path.abspath(os.path.join(current_dir, '..', 'examples')) -fixtures_dir = os.path.realpath(os.path.join(current_dir, 'fixtures')) +"""Tests for tmuxp.""" diff --git a/tests/_internal/__init__.py b/tests/_internal/__init__.py new file mode 100644 index 0000000000..10efabce8c --- /dev/null +++ b/tests/_internal/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp internal modules.""" diff --git a/tests/_internal/conftest.py b/tests/_internal/conftest.py new file mode 100644 index 0000000000..c0630520dd --- /dev/null +++ b/tests/_internal/conftest.py @@ -0,0 +1,32 @@ +"""Shared pytest fixtures for _internal tests.""" + +from __future__ import annotations + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_BRIGHT_CYAN = "\033[96m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) diff --git a/tests/_internal/test_colors.py b/tests/_internal/test_colors.py new file mode 100644 index 0000000000..800c810bd5 --- /dev/null +++ b/tests/_internal/test_colors.py @@ -0,0 +1,362 @@ +"""Tests for _internal color utilities.""" + +from __future__ import annotations + +import sys + +import pytest + +from tests._internal.conftest import ( + ANSI_BLUE, + ANSI_BOLD, + ANSI_BRIGHT_CYAN, + ANSI_CYAN, + ANSI_GREEN, + ANSI_MAGENTA, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + style, +) + +# ColorMode tests + + +def test_auto_tty_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors enabled when stdout is TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_auto_no_tty_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors disabled when stdout is not TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is False + + +def test_no_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR environment variable disables colors even in ALWAYS mode.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_no_color_any_value(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR with any non-empty value disables colors.""" + monkeypatch.setenv("NO_COLOR", "yes") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_force_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_never_mode_disables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.NEVER always disables colors.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.NEVER) + assert colors._enabled is False + assert colors.success("test") == "test" + assert colors.error("fail") == "fail" + assert colors.warning("warn") == "warn" + assert colors.info("info") == "info" + assert colors.highlight("hl") == "hl" + assert colors.muted("mute") == "mute" + + +def test_always_mode_enables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is True + assert "\033[" in colors.success("test") + + +# Semantic color tests + + +def test_success_applies_green(monkeypatch: pytest.MonkeyPatch) -> None: + """success() applies green color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("ok") + assert ANSI_GREEN in result + assert "ok" in result + assert result.endswith(ANSI_RESET) + + +def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: + """error() applies red color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.error("fail") + assert ANSI_RED in result + assert "fail" in result + + +def test_warning_applies_yellow(monkeypatch: pytest.MonkeyPatch) -> None: + """warning() applies yellow color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.warning("caution") + assert ANSI_YELLOW in result + assert "caution" in result + + +def test_info_applies_cyan(monkeypatch: pytest.MonkeyPatch) -> None: + """info() applies cyan color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.info("message") + assert ANSI_CYAN in result + assert "message" in result + + +def test_highlight_applies_magenta_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() applies magenta color with bold by default.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important") + assert ANSI_MAGENTA in result + assert ANSI_BOLD in result + assert "important" in result + + +def test_highlight_no_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() can be used without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important", bold=False) + assert ANSI_MAGENTA in result + assert ANSI_BOLD not in result + assert "important" in result + + +def test_muted_applies_blue(monkeypatch: pytest.MonkeyPatch) -> None: + """muted() applies blue color without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.muted("secondary") + assert ANSI_BLUE in result + assert ANSI_BOLD not in result + assert "secondary" in result + + +def test_success_with_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """success() can be used with bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("done", bold=True) + assert ANSI_GREEN in result + assert ANSI_BOLD in result + assert "done" in result + + +# get_color_mode tests + + +def test_get_color_mode_none_returns_auto() -> None: + """None argument returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_auto_string() -> None: + """'auto' string returns AUTO mode.""" + assert get_color_mode("auto") == ColorMode.AUTO + + +def test_get_color_mode_always_string() -> None: + """'always' string returns ALWAYS mode.""" + assert get_color_mode("always") == ColorMode.ALWAYS + + +def test_get_color_mode_never_string() -> None: + """'never' string returns NEVER mode.""" + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Color mode strings are case insensitive.""" + assert get_color_mode("ALWAYS") == ColorMode.ALWAYS + assert get_color_mode("Never") == ColorMode.NEVER + assert get_color_mode("AUTO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_returns_auto() -> None: + """Invalid color mode strings return AUTO as fallback.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO + + +# Colors class attribute tests + + +def test_semantic_color_names() -> None: + """Verify semantic color name attributes exist.""" + assert Colors.SUCCESS == "green" + assert Colors.WARNING == "yellow" + assert Colors.ERROR == "red" + assert Colors.INFO == "cyan" + assert Colors.HIGHLIGHT == "magenta" + assert Colors.MUTED == "blue" + + +# Colors disabled tests + + +def test_disabled_returns_plain_text() -> None: + """When colors are disabled, methods return plain text.""" + colors = Colors(ColorMode.NEVER) + assert colors.success("text") == "text" + assert colors.error("text") == "text" + assert colors.warning("text") == "text" + assert colors.info("text") == "text" + assert colors.highlight("text") == "text" + assert colors.muted("text") == "text" + + +def test_disabled_preserves_text() -> None: + """Disabled colors preserve special characters.""" + colors = Colors(ColorMode.NEVER) + special = "path/to/file.yaml" + assert colors.info(special) == special + + with_spaces = "some message" + assert colors.success(with_spaces) == with_spaces + + +# RGB tuple validation tests + + +def test_style_with_valid_rgb_tuple() -> None: + """style() should accept valid RGB tuple.""" + result = style("test", fg=(255, 128, 0)) + assert "\033[38;2;255;128;0m" in result + assert "test" in result + + +def test_style_with_invalid_2_element_tuple() -> None: + """style() should raise UnknownStyleColor for 2-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128)) # type: ignore[arg-type] + + +def test_style_with_invalid_4_element_tuple() -> None: + """style() should raise UnknownStyleColor for 4-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128, 0, 64)) # type: ignore[arg-type] + + +def test_style_with_empty_tuple() -> None: + """style() treats empty tuple as 'no color' (falsy value).""" + result = style("test", fg=()) # type: ignore[arg-type] + # Empty tuple is falsy, so no fg color is applied + assert "test" in result + assert "\033[38" not in result # No foreground color escape + + +def test_style_with_rgb_value_too_high() -> None: + """style() should reject RGB values > 255.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(256, 0, 0)) + + +def test_style_with_rgb_value_negative() -> None: + """style() should reject negative RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(-1, 128, 0)) + + +def test_style_with_rgb_non_integer() -> None: + """style() should reject non-integer RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255.5, 128, 0)) # type: ignore[arg-type] + + +# heading() method tests + + +def test_heading_applies_bright_cyan_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """heading() applies bright_cyan with bold when colors are enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.heading("Local workspaces:") + assert ANSI_BRIGHT_CYAN in result + assert ANSI_BOLD in result + assert "Local workspaces:" in result + assert ANSI_RESET in result + + +# build_description tests + + +def test_build_description_named_heading_includes_examples_suffix() -> None: + """Named heading should include 'examples:' suffix for formatter detection.""" + result = build_description("My tool.", [("sync", ["mytool sync repo"])]) + + # Should be "sync examples:" not just "sync:" + assert "sync examples:" in result + # Verify the old format is not present (unless contained in "sync examples:") + lines = result.split("\n") + heading_line = next(line for line in lines if "sync" in line.lower()) + assert heading_line == "sync examples:" + + +def test_build_description_no_heading_uses_examples() -> None: + """Heading=None should produce bare 'examples:' title.""" + result = build_description("My tool.", [(None, ["mytool run"])]) + + assert "examples:" in result + assert result.count("examples:") == 1 # Just one + + +def test_build_description_multiple_named_headings() -> None: + """Multiple named headings should all have 'examples:' suffix.""" + result = build_description( + "My tool.", + [ + ("load", ["mytool load"]), + ("freeze", ["mytool freeze"]), + ("ls", ["mytool ls"]), + ], + ) + + assert "load examples:" in result + assert "freeze examples:" in result + assert "ls examples:" in result + + +def test_build_description_empty_intro() -> None: + """Empty intro should not add blank sections.""" + result = build_description("", [(None, ["cmd"])]) + + assert result.startswith("examples:") + assert "cmd" in result diff --git a/tests/_internal/test_colors_formatters.py b/tests/_internal/test_colors_formatters.py new file mode 100644 index 0000000000..c7f9d80297 --- /dev/null +++ b/tests/_internal/test_colors_formatters.py @@ -0,0 +1,236 @@ +"""Tests for Colors class formatting helper methods.""" + +from __future__ import annotations + +import pytest + +from tests._internal.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.colors import ColorMode, Colors + +# format_label tests + + +def test_format_label_plain_text() -> None: + """format_label returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_label("tmux path") == "tmux path" + + +def test_format_label_applies_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """format_label applies highlight (bold magenta) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_label("tmux path") + assert ANSI_MAGENTA in result # magenta + assert ANSI_BOLD in result # bold + assert "tmux path" in result + + +# format_path tests + + +def test_format_path_plain_text() -> None: + """format_path returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_path("/usr/bin/tmux") == "/usr/bin/tmux" + + +def test_format_path_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_path applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_path("/usr/bin/tmux") + assert ANSI_CYAN in result # cyan + assert "/usr/bin/tmux" in result + + +# format_version tests + + +def test_format_version_plain_text() -> None: + """format_version returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_version("3.2a") == "3.2a" + + +def test_format_version_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_version applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_version("3.2a") + assert ANSI_CYAN in result # cyan + assert "3.2a" in result + + +# format_separator tests + + +def test_format_separator_default_length() -> None: + """format_separator creates 25-character separator by default.""" + colors = Colors(ColorMode.NEVER) + result = colors.format_separator() + assert result == "-" * 25 + + +def test_format_separator_custom_length() -> None: + """format_separator respects custom length.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_separator(10) == "-" * 10 + assert colors.format_separator(50) == "-" * 50 + + +def test_format_separator_applies_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """format_separator applies muted color (blue) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_separator() + assert ANSI_BLUE in result # blue + assert "-" * 25 in result + + +# format_kv tests + + +def test_format_kv_plain_text() -> None: + """format_kv returns plain key: value when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("tmux version", "3.2a") == "tmux version: 3.2a" + + +def test_format_kv_highlights_key(monkeypatch: pytest.MonkeyPatch) -> None: + """format_kv highlights the key but not the value.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_BOLD in result # bold for key + assert "tmux version" in result + assert ": 3.2a" in result + + +def test_format_kv_empty_value() -> None: + """format_kv handles empty value.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("environment", "") == "environment: " + + +# format_tmux_option tests + + +def test_format_tmux_option_plain_text_key_value() -> None: + """format_tmux_option returns plain text when colors disabled (key=value).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("base-index=1") == "base-index=1" + + +def test_format_tmux_option_plain_text_space_sep() -> None: + """format_tmux_option returns plain text when colors disabled (space-sep).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("status on") == "status on" + + +def test_format_tmux_option_key_value_format(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("base-index=1") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "=1" in result or "1" in result + + +def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights space-separated format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_format_tmux_option_single_word() -> None: + """format_tmux_option returns single words (empty array options) unchanged.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("pane-colours") == "pane-colours" + + +def test_format_tmux_option_single_word_highlighted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option highlights single words (empty array options) as keys.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("pane-colours") + assert ANSI_MAGENTA in result # magenta for key + assert "pane-colours" in result + + +def test_format_tmux_option_empty() -> None: + """format_tmux_option handles empty string.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("") == "" + + +def test_format_tmux_option_array_indexed(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option handles array-indexed keys like status-format[0].""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option('status-format[0] "#[align=left]"') + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status-format[0]" in result + assert "#[align=left]" in result + + +def test_format_tmux_option_array_indexed_complex_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles complex format strings as values.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Real tmux status-format value (truncated for test) + line = 'status-format[0] "#[align=left range=left #{E:status-left-style}]"' + result = colors.format_tmux_option(line) + assert "status-format[0]" in result + assert "#[align=left" in result + + +def test_format_tmux_option_value_with_spaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles values containing spaces.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # tmux options can have values with spaces like status-left "string here" + result = colors.format_tmux_option('status-left "#S: #W"') + assert "status-left" in result + assert '"#S: #W"' in result + + +def test_format_tmux_option_value_with_equals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option splits only on first equals for key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Only split on first equals (no spaces = key=value format) + result = colors.format_tmux_option("option=a=b=c") + assert "option" in result + assert "a=b=c" in result + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value diff --git a/tests/_internal/test_colors_integration.py b/tests/_internal/test_colors_integration.py new file mode 100644 index 0000000000..5927a3b71b --- /dev/null +++ b/tests/_internal/test_colors_integration.py @@ -0,0 +1,193 @@ +"""Integration tests for color output across all commands.""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +from tests._internal.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET +from tmuxp._internal.colors import ColorMode, Colors, get_color_mode + +# Color flag integration tests + + +def test_color_flag_auto_with_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto enables colors when stdout is TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is True + + +def test_color_flag_auto_without_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto disables colors when stdout is not TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is False + + +def test_color_flag_always_forces_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=always forces colors even without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is True + # Verify output contains ANSI codes + assert "\033[" in colors.success("test") + + +def test_color_flag_never_disables_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=never disables colors even with TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("never") + colors = Colors(color_mode) + assert colors._enabled is False + # Verify output is plain text + assert colors.success("test") == "test" + assert colors.error("test") == "test" + assert colors.warning("test") == "test" + + +# Environment variable integration tests + + +def test_no_color_env_overrides_always(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR environment variable overrides --color=always.""" + monkeypatch.setenv("NO_COLOR", "1") + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is False + assert colors.success("test") == "test" + + +def test_no_color_env_with_empty_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty NO_COLOR is ignored (per spec).""" + monkeypatch.setenv("NO_COLOR", "") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + colors = Colors(ColorMode.ALWAYS) + # Empty NO_COLOR should be ignored, colors should be enabled + assert colors._enabled is True + + +def test_force_color_env_with_auto(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify FORCE_COLOR enables colors in auto mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence_over_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +# Color mode consistency tests + + +def test_all_semantic_methods_respect_enabled_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify all semantic color methods include ANSI codes when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert "\033[" in result, f"{method.__name__} should include ANSI codes" + assert result.endswith(ANSI_RESET), f"{method.__name__} should reset color" + + +def test_all_semantic_methods_respect_disabled_state() -> None: + """Verify all semantic color methods return plain text when disabled.""" + colors = Colors(ColorMode.NEVER) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert result == "test", f"{method.__name__} should return plain text" + assert "\033[" not in result, f"{method.__name__} should not have ANSI codes" + + +def test_highlight_bold_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify highlight respects bold parameter.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + with_bold = colors.highlight("test", bold=True) + without_bold = colors.highlight("test", bold=False) + + assert ANSI_BOLD in with_bold + assert ANSI_BOLD not in without_bold + # Both should have magenta + assert ANSI_MAGENTA in with_bold + assert ANSI_MAGENTA in without_bold + + +# get_color_mode function tests + + +def test_get_color_mode_none_defaults_to_auto() -> None: + """Verify None input returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_valid_string_values() -> None: + """Verify all valid string values are converted correctly.""" + assert get_color_mode("auto") == ColorMode.AUTO + assert get_color_mode("always") == ColorMode.ALWAYS + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Verify string values are case insensitive.""" + assert get_color_mode("AUTO") == ColorMode.AUTO + assert get_color_mode("Always") == ColorMode.ALWAYS + assert get_color_mode("NEVER") == ColorMode.NEVER + assert get_color_mode("aUtO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_values_fallback_to_auto() -> None: + """Verify invalid values fallback to AUTO mode.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("no") == ColorMode.AUTO + assert get_color_mode("true") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO diff --git a/tests/_internal/test_private_path.py b/tests/_internal/test_private_path.py new file mode 100644 index 0000000000..7e9f3f4979 --- /dev/null +++ b/tests/_internal/test_private_path.py @@ -0,0 +1,124 @@ +"""Tests for PrivatePath privacy-masking utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string + +# PrivatePath tests + + +def test_private_path_collapses_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath replaces home directory with ~.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/projects/tmuxp") + assert str(path) == "~/projects/tmuxp" + + +def test_private_path_collapses_home_exact(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath handles exact home directory match.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser") + assert str(path) == "~" + + +def test_private_path_preserves_non_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves paths outside home directory.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/usr/bin/tmux") + assert str(path) == "/usr/bin/tmux" + + +def test_private_path_preserves_tmp(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves /tmp paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/tmp/example") + assert str(path) == "/tmp/example" + + +def test_private_path_preserves_already_collapsed() -> None: + """PrivatePath preserves paths already starting with ~.""" + path = PrivatePath("~/already/collapsed") + assert str(path) == "~/already/collapsed" + + +def test_private_path_repr(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath repr shows class name and collapsed path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/config.yaml") + assert repr(path) == "PrivatePath('~/config.yaml')" + + +def test_private_path_in_fstring(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath works in f-strings with collapsed home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/.tmuxp/session.yaml") + result = f"config: {path}" + assert result == "config: ~/.tmuxp/session.yaml" + + +def test_private_path_similar_prefix_not_collapsed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PrivatePath does not collapse paths with similar prefix but different user.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + # /home/testuser2 should NOT be collapsed even though it starts with /home/testuser + path = PrivatePath("/home/testuser2/projects") + assert str(path) == "/home/testuser2/projects" + + +# collapse_home_in_string tests + + +def test_collapse_home_in_string_single_path(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles a single path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/home/testuser/.local/bin") + assert result == "~/.local/bin" + + +def test_collapse_home_in_string_multiple_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string handles colon-separated paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string( + "/home/testuser/bin:/home/testuser/.cargo/bin:/usr/bin" + ) + assert result == "~/bin:~/.cargo/bin:/usr/bin" + + +def test_collapse_home_in_string_no_home_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string preserves paths not under home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/bin:/usr/local/bin") + assert result == "/usr/bin:/bin:/usr/local/bin" + + +def test_collapse_home_in_string_mixed_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles mixed home and non-home paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/home/testuser/.local/bin:/bin") + assert result == "/usr/bin:~/.local/bin:/bin" + + +def test_collapse_home_in_string_empty() -> None: + """collapse_home_in_string handles empty string.""" + result = collapse_home_in_string("") + assert result == "" diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000000..95437011db --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""CLI tests for tmuxp.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000..32e5748f21 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,63 @@ +"""Shared pytest fixtures for CLI tests.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + +@pytest.fixture +def mock_home(monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Mock home directory for privacy tests. + + Sets pathlib.Path.home() to return /home/testuser. + """ + home = pathlib.Path("/home/testuser") + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + return home + + +@pytest.fixture +def isolated_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> pathlib.Path: + """Isolate test from user's home directory and environment. + + Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears TMUXP_CONFIGDIR + and NO_COLOR, and changes the working directory to tmp_path. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + return tmp_path diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000000..34524946d4 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,175 @@ +"""CLI tests for tmuxp's core shell functionality.""" + +from __future__ import annotations + +import argparse +import contextlib +import pathlib +import typing as t + +import libtmux +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp import cli +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.cli.import_config import get_teamocil_dir, get_tmuxinator_dir +from tmuxp.cli.load import _reattach, load_plugins +from tmuxp.cli.utils import tmuxp_echo +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder +from tmuxp.workspace.finders import find_workspace_file + +if t.TYPE_CHECKING: + import _pytest.capture + from libtmux.server import Server + + +def test_creates_config_dir_not_exists(tmp_path: pathlib.Path) -> None: + """cli.startup() creates config dir if not exists.""" + cli.startup(tmp_path) + assert tmp_path.exists() + + +class HelpTestFixture(t.NamedTuple): + """Test fixture for help command tests.""" + + test_id: str + cli_args: list[str] + + +HELP_TEST_FIXTURES: list[HelpTestFixture] = [ + HelpTestFixture( + test_id="help_long_flag", + cli_args=["--help"], + ), + HelpTestFixture( + test_id="help_short_flag", + cli_args=["-h"], + ), +] + + +@pytest.mark.parametrize( + list(HelpTestFixture._fields), + HELP_TEST_FIXTURES, + ids=[test.test_id for test in HELP_TEST_FIXTURES], +) +def test_help( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test tmuxp --help / -h.""" + # In scrunched terminals, prevent width causing differentiation in result.out. + monkeypatch.setenv("COLUMNS", "100") + monkeypatch.setenv("LINES", "100") + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "usage: tmuxp [-h] [--version] [--log-level log-level]" in result.out + + +def test_resolve_behavior( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test resolution of file paths.""" + expect = tmp_path + monkeypatch.chdir(tmp_path) + assert pathlib.Path("../").resolve() == expect.parent + assert pathlib.Path.cwd() == expect + assert pathlib.Path("./").resolve() == expect + assert pathlib.Path(expect).resolve() == expect + + +def test_get_tmuxinator_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_tmuxinator_dir() helper function.""" + assert get_tmuxinator_dir() == pathlib.Path("~/.tmuxinator").expanduser() + + monkeypatch.setenv("HOME", "/moo") + assert get_tmuxinator_dir() == pathlib.Path("/moo/.tmuxinator/") + assert str(get_tmuxinator_dir()) == "/moo/.tmuxinator" + assert get_tmuxinator_dir() == pathlib.Path("~/.tmuxinator/").expanduser() + + +def test_get_teamocil_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_teamocil_dir() helper function.""" + assert get_teamocil_dir() == pathlib.Path("~/.teamocil/").expanduser() + + monkeypatch.setenv("HOME", "/moo") + assert get_teamocil_dir() == pathlib.Path("/moo/.teamocil/") + assert str(get_teamocil_dir()) == "/moo/.teamocil" + assert get_teamocil_dir() == pathlib.Path("~/.teamocil/").expanduser() + + +def test_pass_config_dir_argparse( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test workspace configurations can be detected via directory.""" + configdir = tmp_path / "myconfigdir" + configdir.mkdir() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + expect = str(user_config) + + parser = argparse.ArgumentParser() + parser.add_argument("workspace_file", type=str) + + def config_cmd(workspace_file: str) -> None: + tmuxp_echo(find_workspace_file(workspace_file, workspace_dir=configdir)) + + def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: + args = parser.parse_args([config_arg]) + config_cmd(workspace_file=args.workspace_file) + return capsys.readouterr() + + monkeypatch.chdir(configdir) + + assert expect in check_cmd("myconfig").out + assert expect in check_cmd("myconfig.yaml").out + assert expect in check_cmd("./myconfig.yaml").out + assert str(user_config) in check_cmd(str(configdir / "myconfig.yaml")).out + + with pytest.raises(FileNotFoundError): + assert "FileNotFoundError" in check_cmd(".tmuxp.json").out + + +def test_reattach_plugins( + monkeypatch_plugin_test_packages: None, + server: Server, +) -> None: + """Test reattach plugin hook.""" + config_plugins = test_utils.read_workspace_file("workspace/builder/plugin_r.yaml") + + session_config = ConfigReader._load(fmt="yaml", content=config_plugins) + session_config = loader.expand(session_config) + + # open it detached + builder = WorkspaceBuilder( + session_config=session_config, + plugins=load_plugins(session_config), + server=server, + ) + builder.build() + + with contextlib.suppress(libtmux.exc.LibTmuxException): + _reattach(builder) + + assert builder.session is not None + proc = builder.session.cmd("display-message", "-p", "'#S'") + + assert proc.stdout[0] == "'plugin_test_r'" diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py new file mode 100644 index 0000000000..8cafecd12c --- /dev/null +++ b/tests/cli/test_convert.py @@ -0,0 +1,140 @@ +"""CLI tests for tmuxp convert.""" + +from __future__ import annotations + +import contextlib +import io +import json +import typing as t + +import pytest + +from tmuxp import cli + +if t.TYPE_CHECKING: + import pathlib + + +class ConvertTestFixture(t.NamedTuple): + """Test fixture for tmuxp convert command tests.""" + + test_id: str + cli_args: list[str] + + +CONVERT_TEST_FIXTURES: list[ConvertTestFixture] = [ + ConvertTestFixture( + test_id="convert_current_dir", + cli_args=["convert", "."], + ), + ConvertTestFixture( + test_id="convert_yaml_file", + cli_args=["convert", ".tmuxp.yaml"], + ), + ConvertTestFixture( + test_id="convert_yaml_file_auto_confirm", + cli_args=["convert", ".tmuxp.yaml", "-y"], + ), + ConvertTestFixture( + test_id="convert_yml_file", + cli_args=["convert", ".tmuxp.yml"], + ), + ConvertTestFixture( + test_id="convert_yml_file_auto_confirm", + cli_args=["convert", ".tmuxp.yml", "-y"], + ), +] + + +@pytest.mark.parametrize( + list(ConvertTestFixture._fields), + CONVERT_TEST_FIXTURES, + ids=[test.test_id for test in CONVERT_TEST_FIXTURES], +) +def test_convert( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Parametrized tests for tmuxp convert.""" + # create dummy tmuxp yaml so we don't get yelled at + filename = cli_args[1] + if filename == ".": + filename = ".tmuxp.yaml" + file_ext = filename.rsplit(".", 1)[-1] + assert file_ext in {"yaml", "yml"}, file_ext + workspace_file_path = tmp_path / filename + workspace_file_path.write_text("\nsession_name: hello\n", encoding="utf-8") + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + # If autoconfirm (-y) no need to prompt y + input_args = "y\ny\n" if "-y" not in cli_args else "" + + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + tmuxp_json = tmp_path / ".tmuxp.json" + assert tmuxp_json.exists() + assert tmuxp_json.open().read() == json.dumps({"session_name": "hello"}, indent=2) + + +class ConvertJsonTestFixture(t.NamedTuple): + """Test fixture for tmuxp convert json command tests.""" + + test_id: str + cli_args: list[str] + + +CONVERT_JSON_TEST_FIXTURES: list[ConvertJsonTestFixture] = [ + ConvertJsonTestFixture( + test_id="convert_json_current_dir", + cli_args=["convert", "."], + ), + ConvertJsonTestFixture( + test_id="convert_json_file", + cli_args=["convert", ".tmuxp.json"], + ), + ConvertJsonTestFixture( + test_id="convert_json_file_auto_confirm", + cli_args=["convert", ".tmuxp.json", "-y"], + ), +] + + +@pytest.mark.parametrize( + list(ConvertJsonTestFixture._fields), + CONVERT_JSON_TEST_FIXTURES, + ids=[test.test_id for test in CONVERT_JSON_TEST_FIXTURES], +) +def test_convert_json( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test using tmuxp convert to convert configuration from json to yaml.""" + # create dummy tmuxp yaml so we don't get yelled at + json_config = tmp_path / ".tmuxp.json" + json_config.write_text('{"session_name": "hello"}', encoding="utf-8") + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + # If autoconfirm (-y) no need to prompt y + input_args = "y\ny\n" if "-y" not in cli_args else "" + + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + tmuxp_yaml = tmp_path / ".tmuxp.yaml" + assert tmuxp_yaml.exists() + assert tmuxp_yaml.open().read() == "session_name: hello\n" diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py new file mode 100644 index 0000000000..5d395553a6 --- /dev/null +++ b/tests/cli/test_convert_colors.py @@ -0,0 +1,124 @@ +"""Tests for CLI colors in convert command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BOLD, ANSI_CYAN, ANSI_GREEN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Convert command color output tests + + +def test_convert_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("New workspace file saved to ") + assert ANSI_GREEN in result # green foreground + assert "New workspace file saved to" in result + + +def test_convert_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_convert_format_type_highlighted(colors_always: Colors) -> None: + """Verify format type uses highlight color (magenta + bold).""" + for fmt in ["json", "yaml"]: + result = colors_always.highlight(fmt) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD in result # bold + assert fmt in result + + +def test_convert_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.success("success") == "success" + assert colors_never.info("info") == "info" + assert colors_never.highlight("highlight") == "highlight" + + +def test_convert_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for save message.""" + newfile = "/home/user/.tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(f"<{newfile}>") + + "." + ) + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for success text + assert ANSI_CYAN in output # cyan for path + assert "New workspace file saved to" in output + assert newfile in output + assert output.endswith(".") + + +def test_convert_prompt_format_with_highlight(colors_always: Colors) -> None: + """Verify prompt uses info for path and highlight for format.""" + workspace_file = "/path/to/config.yaml" + to_filetype = "json" + prompt = ( + f"Convert {colors_always.info(workspace_file)} " + f"to {colors_always.highlight(to_filetype)}?" + ) + assert ANSI_CYAN in prompt # cyan for file path + assert ANSI_MAGENTA in prompt # magenta for format type + assert workspace_file in prompt + assert to_filetype in prompt + + +def test_convert_save_prompt_format(colors_always: Colors) -> None: + """Verify save prompt uses info color for new file path.""" + newfile = "/path/to/config.json" + prompt = f"Save workspace to {colors_always.info(newfile)}?" + assert ANSI_CYAN in prompt # cyan for file path + assert newfile in prompt + assert "Save workspace to" in prompt + + +# Privacy masking tests + + +def test_convert_masks_home_in_convert_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in convert prompt.""" + workspace_file = mock_home / ".tmuxp/session.yaml" + prompt = f"Convert {colors_always.info(str(PrivatePath(workspace_file)))} to json?" + + assert "~/.tmuxp/session.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_save_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in save prompt.""" + newfile = mock_home / ".tmuxp/session.json" + prompt = f"Save workspace to {colors_always.info(str(PrivatePath(newfile)))}?" + + assert "~/.tmuxp/session.json" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in saved message.""" + newfile = mock_home / ".tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(str(PrivatePath(newfile))) + + "." + ) + + assert "~/.tmuxp/session.json" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_debug_info.py b/tests/cli/test_debug_info.py new file mode 100644 index 0000000000..9aba904d13 --- /dev/null +++ b/tests/cli/test_debug_info.py @@ -0,0 +1,192 @@ +"""CLI tests for tmuxp debug-info.""" + +from __future__ import annotations + +import json +import typing as t + +import pytest + +from tmuxp import cli + +if t.TYPE_CHECKING: + import pathlib + + +class DebugInfoOutputFixture(t.NamedTuple): + """Test fixture for debug-info output modes.""" + + test_id: str + args: list[str] + expected_keys: list[str] + is_json: bool + + +DEBUG_INFO_OUTPUT_FIXTURES: list[DebugInfoOutputFixture] = [ + DebugInfoOutputFixture( + test_id="human_output_has_labels", + args=["debug-info"], + expected_keys=["environment", "python version", "tmux version"], + is_json=False, + ), + DebugInfoOutputFixture( + test_id="json_output_valid", + args=["debug-info", "--json"], + expected_keys=["environment", "python_version", "tmux_version"], + is_json=True, + ), +] + + +@pytest.mark.parametrize( + DEBUG_INFO_OUTPUT_FIXTURES[0]._fields, + [pytest.param(*f, id=f.test_id) for f in DEBUG_INFO_OUTPUT_FIXTURES], +) +def test_debug_info_output_modes( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + args: list[str], + expected_keys: list[str], + is_json: bool, +) -> None: + """Test debug-info output modes (human and JSON).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(args) + output = capsys.readouterr().out + + if is_json: + data = json.loads(output) + for key in expected_keys: + assert key in data, f"Expected key '{key}' in JSON output" + else: + for key in expected_keys: + assert key in output, f"Expected '{key}' in human output" + + +def test_debug_info_cli( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Basic CLI test for tmuxp debug-info (human output).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info"]) + cli_output = capsys.readouterr().out + assert "environment" in cli_output + assert "python version" in cli_output + assert "system PATH" in cli_output + assert "tmux version" in cli_output + assert "libtmux version" in cli_output + assert "tmuxp version" in cli_output + assert "tmux path" in cli_output + assert "tmuxp path" in cli_output + assert "shell" in cli_output + assert "tmux session" in cli_output + assert "tmux windows" in cli_output + assert "tmux panes" in cli_output + assert "tmux global options" in cli_output + assert "tmux window options" in cli_output + + +def test_debug_info_json_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output is valid JSON with expected structure.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + data = json.loads(output) + + # Top-level keys + assert "environment" in data + assert "python_version" in data + assert "system_path" in data + assert "tmux_version" in data + assert "libtmux_version" in data + assert "tmuxp_version" in data + assert "tmux_path" in data + assert "tmuxp_path" in data + assert "shell" in data + assert "tmux" in data + + # Environment structure + env = data["environment"] + assert "dist" in env + assert "arch" in env + assert "uname" in env + assert "version" in env + assert isinstance(env["uname"], list) + + # Tmux structure + tmux = data["tmux"] + assert "sessions" in tmux + assert "windows" in tmux + assert "panes" in tmux + assert "global_options" in tmux + assert "window_options" in tmux + assert isinstance(tmux["sessions"], list) + + +def test_debug_info_json_no_ansi( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should not contain ANSI escape codes.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + # ANSI escape codes start with \x1b[ or \033[ + assert "\x1b[" not in output, "JSON output contains ANSI escape codes" + assert "\033[" not in output, "JSON output contains ANSI escape codes" + + +def test_debug_info_json_paths_use_private_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should mask home directory with ~.""" + import pathlib + + # Set SHELL to a path under home directory + shell_path = pathlib.Path.home() / ".local" / "bin" / "zsh" + monkeypatch.setenv("SHELL", str(shell_path)) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + # The shell path should be masked with ~ + assert data["shell"] == "~/.local/bin/zsh", ( + f"Expected shell path to be masked with ~, got: {data['shell']}" + ) + + +def test_debug_info_reports_raw_tmux_version( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """debug-info reports tmux's raw version, keeping the point-release suffix. + + libtmux's ``get_version()`` strips the letter suffix (``"3.7a"`` -> ``"3.7"``) + for numeric comparison; ``get_version_str()`` keeps it. Bug reports want the + exact patch release, so debug-info reads the raw string verbatim. + """ + monkeypatch.setenv("SHELL", "/bin/bash") + monkeypatch.setattr( + "tmuxp.cli.debug_info.get_version_str", + lambda *args, **kwargs: "3.7a", + ) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + assert data["tmux_version"] == "3.7a" diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py new file mode 100644 index 0000000000..ec2ac283db --- /dev/null +++ b/tests/cli/test_debug_info_colors.py @@ -0,0 +1,154 @@ +"""Tests for debug-info command color output and privacy masking.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from tmuxp.cli._colors import ColorMode, Colors + +# Privacy masking in debug-info context + + +def test_debug_info_masks_home_in_paths(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in paths.""" + # Simulate what debug-info does with tmuxp_path + tmuxp_path = mock_home / "work/python/tmuxp/src/tmuxp" + private_path = str(PrivatePath(tmuxp_path)) + + assert private_path == "~/work/python/tmuxp/src/tmuxp" + assert "/home/testuser" not in private_path + + +def test_debug_info_masks_home_in_system_path(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in system PATH.""" + path_env = "/home/testuser/.local/bin:/usr/bin:/home/testuser/.cargo/bin" + masked = collapse_home_in_string(path_env) + + assert masked == "~/.local/bin:/usr/bin:~/.cargo/bin" + assert "/home/testuser" not in masked + + +def test_debug_info_preserves_system_paths(mock_home: pathlib.Path) -> None: + """debug-info should preserve paths outside home directory.""" + tmux_path = "/usr/bin/tmux" + private_path = str(PrivatePath(tmux_path)) + + assert private_path == "/usr/bin/tmux" + + +# Formatting helpers in debug-info context + + +def test_debug_info_format_kv_labels(colors_always: Colors) -> None: + """debug-info should highlight labels in key-value pairs.""" + result = colors_always.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for label + assert ANSI_BOLD in result # bold for label + assert "tmux version" in result + assert "3.2a" in result + + +def test_debug_info_format_version(colors_always: Colors) -> None: + """debug-info should highlight version strings.""" + result = colors_always.format_kv( + "tmux version", colors_always.format_version("3.2a") + ) + assert ANSI_CYAN in result # cyan for version + assert "3.2a" in result + + +def test_debug_info_format_path(colors_always: Colors) -> None: + """debug-info should highlight paths.""" + result = colors_always.format_kv( + "tmux path", colors_always.format_path("/usr/bin/tmux") + ) + assert ANSI_CYAN in result # cyan for path + assert "/usr/bin/tmux" in result + + +def test_debug_info_format_separator(colors_always: Colors) -> None: + """debug-info should use muted separators.""" + result = colors_always.format_separator() + assert ANSI_BLUE in result # blue for muted + assert "-" * 25 in result + + +# tmux option formatting + + +def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: + """debug-info should format space-separated tmux options.""" + result = colors_always.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_debug_info_format_tmux_option_equals_sep(colors_always: Colors) -> None: + """debug-info should format equals-separated tmux options.""" + result = colors_always.format_tmux_option("base-index=0") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "0" in result + + +# Color mode behavior + + +def test_debug_info_respects_never_mode(colors_never: Colors) -> None: + """debug-info should return plain text in NEVER mode.""" + result = colors_never.format_kv("tmux version", colors_never.format_version("3.2a")) + assert "\033[" not in result + assert result == "tmux version: 3.2a" + + +def test_debug_info_respects_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should respect NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux path", "/usr/bin/tmux") + assert "\033[" not in result + assert result == "tmux path: /usr/bin/tmux" + + +# Combined formatting + + +def test_debug_info_combined_path_with_privacy( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """debug-info should combine privacy masking with color formatting.""" + # Simulate what debug-info does + raw_path = mock_home / "work/tmuxp/src/tmuxp" + private_path = str(PrivatePath(raw_path)) + formatted = colors_always.format_kv( + "tmuxp path", colors_always.format_path(private_path) + ) + + assert "~/work/tmuxp/src/tmuxp" in formatted + assert "/home/testuser" not in formatted + assert ANSI_CYAN in formatted # cyan for path + assert ANSI_MAGENTA in formatted # magenta for label + + +def test_debug_info_environment_section_format(colors_always: Colors) -> None: + """debug-info environment section should have proper format.""" + # Simulate environment section format + env_items = [ + f"\t{colors_always.format_kv('dist', 'Linux-6.6.87')}", + f"\t{colors_always.format_kv('arch', 'x86_64')}", + ] + section = f"{colors_always.format_label('environment')}:\n" + "\n".join(env_items) + + assert "environment" in section + assert "\t" in section # indented items + assert "dist" in section + assert "arch" in section diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py new file mode 100644 index 0000000000..53c2663d80 --- /dev/null +++ b/tests/cli/test_edit_colors.py @@ -0,0 +1,101 @@ +"""Tests for CLI colors in edit command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Edit command color output tests + + +def test_edit_opening_message_format(colors_always: Colors) -> None: + """Verify opening message format with file path and editor.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(workspace_file) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + # Should contain blue, cyan, and magenta ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_CYAN in output # cyan for file path + assert ANSI_MAGENTA in output # magenta for editor + assert workspace_file in output + assert editor in output + + +def test_edit_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/workspace.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_edit_editor_highlighted(colors_always: Colors) -> None: + """Verify editor name uses highlight color without bold.""" + for editor in ["vim", "nano", "code", "emacs", "nvim"]: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle + assert editor in result + + +def test_edit_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Opening ") + assert ANSI_BLUE in result # blue foreground + assert "Opening" in result + + +def test_edit_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_never.muted("Opening ") + + colors_never.info(workspace_file) + + colors_never.muted(" in ") + + colors_never.highlight(editor, bold=False) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Opening {workspace_file} in {editor}..." + + +def test_edit_various_editors(colors_always: Colors) -> None: + """Verify common editors can be highlighted.""" + editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] + for editor in editors: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result + assert editor in result + + +# Privacy masking tests + + +def test_edit_masks_home_in_opening_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Edit should mask home directory in 'Opening' message.""" + workspace_file = mock_home / ".tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(str(PrivatePath(workspace_file))) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + + assert "~/.tmuxp/dev.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py new file mode 100644 index 0000000000..3f1ba3782e --- /dev/null +++ b/tests/cli/test_formatter.py @@ -0,0 +1,264 @@ +"""Tests for TmuxpHelpFormatter and themed formatter factory.""" + +from __future__ import annotations + +import argparse + +import pytest + +from tests.cli.conftest import ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._formatter import ( + HelpTheme, + TmuxpHelpFormatter, + create_themed_formatter, +) + + +def test_create_themed_formatter_returns_subclass() -> None: + """Factory returns a TmuxpHelpFormatter subclass.""" + formatter_cls = create_themed_formatter() + assert issubclass(formatter_cls, TmuxpHelpFormatter) + + +def test_create_themed_formatter_with_colors_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Formatter has theme when colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is not None + assert formatter._theme.prog != "" # Has color codes + + +def test_create_themed_formatter_with_colors_disabled() -> None: + """Formatter has no theme when colors disabled.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects FORCE_COLOR environment variable.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is not None + + +def test_fill_text_with_theme_colorizes_examples( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Examples section is colorized when theme is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should contain ANSI escape codes + assert "\033[" in result + assert "tmuxp" in result + assert "load" in result + + +def test_fill_text_without_theme_plain_text() -> None: + """Examples section is plain text when no theme.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should NOT contain ANSI escape codes + assert "\033[" not in result + assert "tmuxp load myproject" in result + + +def test_fill_text_category_headings_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings within examples block are colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Test category heading without "examples:" suffix + text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized + assert "\033[" in result + assert "examples:" in result + assert "Machine-readable output:" in result + # Commands should also be colorized + assert "tmuxp" in result + assert "--json" in result + + +def test_fill_text_category_heading_only_in_examples_block( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings are only recognized within examples block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Text before examples block should not be colorized as heading + text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" + result = formatter._fill_text(text, 80, "") + + # "Some heading:" should NOT be colorized (it's before examples block) + # "examples:" and the command should be colorized + lines = result.split("\n") + # First line should be plain (no ANSI in "Some heading:") + assert "Some heading:" in lines[0] + + +def test_parser_help_respects_no_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is plain when NO_COLOR set.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" not in captured.out + + +def test_parser_help_colorized_with_force_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is colorized when FORCE_COLOR set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" in captured.out + + +def test_help_theme_from_colors_with_none_returns_empty() -> None: + """HelpTheme.from_colors(None) returns empty theme.""" + theme = HelpTheme.from_colors(None) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_disabled_returns_empty() -> None: + """HelpTheme.from_colors with disabled colors returns empty theme.""" + colors = Colors(ColorMode.NEVER) + theme = HelpTheme.from_colors(colors) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_enabled_returns_colored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HelpTheme.from_colors with enabled colors returns colored theme.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + theme = HelpTheme.from_colors(colors) + + # Should have ANSI codes + assert "\033[" in theme.prog + assert "\033[" in theme.action + assert theme.reset == ANSI_RESET + + +def test_fill_text_section_heading_with_examples_suffix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Section headings ending with 'examples:' are colorized without initial block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # This is what build_description produces - no initial "examples:" block + text = ( + "load examples:\n tmuxp load myproject\n\n" + "freeze examples:\n tmuxp freeze mysession" + ) + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized (contain ANSI escape codes) + assert "\033[" in result + # Commands should be colorized + assert "tmuxp" in result + + +def test_fill_text_multiple_example_sections_all_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multiple 'X examples:' sections should all be colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = """load examples: + tmuxp load myproject + +freeze examples: + tmuxp freeze mysession + +ls examples: + tmuxp ls""" + result = formatter._fill_text(text, 80, "") + + # All sections should have ANSI codes + # Count ANSI escape sequences - should have at least heading + command per section + assert result.count("\033[") >= 6 diff --git a/tests/cli/test_freeze.py b/tests/cli/test_freeze.py new file mode 100644 index 0000000000..fd828dbb4a --- /dev/null +++ b/tests/cli/test_freeze.py @@ -0,0 +1,149 @@ +"""Test workspace freezing functionality for tmuxp.""" + +from __future__ import annotations + +import contextlib +import io +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp._internal.config_reader import ConfigReader + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + + +class FreezeTestFixture(t.NamedTuple): + """Test fixture for tmuxp freeze command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +class FreezeOverwriteTestFixture(t.NamedTuple): + """Test fixture for tmuxp freeze overwrite command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +FREEZE_TEST_FIXTURES: list[FreezeTestFixture] = [ + FreezeTestFixture( + test_id="freeze_named_session", + cli_args=["freeze", "myfrozensession"], + inputs=["y\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_named_session_exists", + cli_args=["freeze", "myfrozensession"], + inputs=["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_current_session", + cli_args=["freeze"], + inputs=["y\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_current_session_exists", + cli_args=["freeze"], + inputs=["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +FREEZE_OVERWRITE_TEST_FIXTURES: list[FreezeOverwriteTestFixture] = [ + FreezeOverwriteTestFixture( + test_id="force_overwrite_named_session", + cli_args=["freeze", "mysession", "--force"], + inputs=["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], + ), + FreezeOverwriteTestFixture( + test_id="force_overwrite_current_session", + cli_args=["freeze", "--force"], + inputs=["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(FreezeTestFixture._fields), + FREEZE_TEST_FIXTURES, + ids=[test.test_id for test in FREEZE_TEST_FIXTURES], +) +def test_freeze( + server: Server, + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Parametrized test for freezing a tmux session to a tmuxp config file.""" + monkeypatch.setenv("HOME", str(tmp_path)) + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + server.new_session(session_name="myfirstsession") + server.new_session(session_name="myfrozensession") + + # Assign an active pane to the session + second_session = server.sessions[1] + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + assert first_pane_on_second_session_id + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + monkeypatch.chdir(tmp_path) + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + yaml_config_path = tmp_path / "la.yaml" + assert yaml_config_path.exists() + + yaml_config = yaml_config_path.open().read() + frozen_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + assert frozen_config["session_name"] == "myfrozensession" + + +@pytest.mark.parametrize( + list(FreezeOverwriteTestFixture._fields), + FREEZE_OVERWRITE_TEST_FIXTURES, + ids=[test.test_id for test in FREEZE_OVERWRITE_TEST_FIXTURES], +) +def test_freeze_overwrite( + server: Server, + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test overwrite prompt when freezing a tmuxp configuration file.""" + monkeypatch.setenv("HOME", str(tmp_path)) + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + server.new_session(session_name="mysession") + + monkeypatch.chdir(tmp_path) + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + yaml_config_path = tmp_path / "exists.yaml" + assert yaml_config_path.exists() diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py new file mode 100644 index 0000000000..3d6afaa012 --- /dev/null +++ b/tests/cli/test_freeze_colors.py @@ -0,0 +1,152 @@ +"""Tests for CLI colors in freeze command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Freeze command color output tests + + +def test_freeze_error_uses_red(colors_always: Colors) -> None: + """Verify error messages use error color (red).""" + msg = "Session not found" + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_freeze_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_freeze_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_freeze_warning_file_exists(colors_always: Colors) -> None: + """Verify file exists warning uses warning color (yellow).""" + msg = "/path/to/config.yaml exists." + result = colors_always.warning(msg) + assert ANSI_YELLOW in result # yellow foreground + assert msg in result + + +def test_freeze_muted_for_secondary_text(colors_always: Colors) -> None: + """Verify secondary text uses muted color (blue).""" + msg = "Freeze does its best to snapshot live tmux sessions." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_freeze_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.warning("warning") == "warning" + assert colors_never.info("info") == "info" + assert colors_never.muted("muted") == "muted" + + +def test_freeze_combined_output_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_freeze_warning_with_instructions(colors_always: Colors) -> None: + """Verify warning + muted format for file exists message.""" + path = "/path/to/config.yaml" + output = ( + colors_always.warning(f"{path} exists.") + + " " + + colors_always.muted("Pick a new filename.") + ) + # Should contain both yellow and blue ANSI codes + assert ANSI_YELLOW in output # yellow for warning + assert ANSI_BLUE in output # blue for muted + assert path in output + assert "Pick a new filename." in output + + +def test_freeze_url_highlighted_in_help(colors_always: Colors) -> None: + """Verify URLs use info color in help text.""" + url = "" + help_text = colors_always.muted("tmuxp has examples at ") + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +# Privacy masking tests + + +def test_freeze_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/session.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/session.yaml" in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_exists_warning( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'exists' warning.""" + dest_prompt = mock_home / ".tmuxp/session.yaml" + output = colors_always.warning(f"{PrivatePath(dest_prompt)} exists.") + + assert "~/.tmuxp/session.yaml exists." in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_save_to_prompt(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to:' prompt.""" + save_to = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to: {PrivatePath(save_to)}" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text + + +def test_freeze_masks_home_in_save_confirmation(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to ...?' confirmation.""" + dest = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to {PrivatePath(dest)}?" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py new file mode 100644 index 0000000000..9cbe365db2 --- /dev/null +++ b/tests/cli/test_help_examples.py @@ -0,0 +1,273 @@ +"""Tests to ensure CLI help examples are valid commands.""" + +from __future__ import annotations + +import argparse +import subprocess + +import pytest + +from tmuxp.cli import create_parser + + +def _get_help_text(subcommand: str | None = None) -> str: + """Get CLI help text without spawning subprocess. + + Parameters + ---------- + subcommand : str | None + Subcommand name, or None for main help. + + Returns + ------- + str + The formatted help text. + """ + parser = create_parser() + if subcommand is None: + return parser.format_help() + + # Access subparser via _subparsers._group_actions + subparsers = parser._subparsers + if subparsers is not None: + for action in subparsers._group_actions: + if isinstance(action, argparse._SubParsersAction): + choices = action.choices + if choices is not None and subcommand in choices: + return str(choices[subcommand].format_help()) + + return parser.format_help() + + +def extract_examples_from_help(help_text: str) -> list[str]: + r"""Extract example commands from help text. + + Parameters + ---------- + help_text : str + The help output text to extract examples from. + + Returns + ------- + list[str] + List of extracted example commands. + + Examples + -------- + >>> text = "load:\n tmuxp load myproject\n\npositions:" + >>> extract_examples_from_help(text) + ['tmuxp load myproject'] + + >>> text2 = "examples:\n tmuxp debug-info\n\noptions:" + >>> extract_examples_from_help(text2) + ['tmuxp debug-info'] + + >>> text3 = "Field-scoped search:\n tmuxp search window:editor" + >>> extract_examples_from_help(text3) + ['tmuxp search window:editor'] + """ + examples = [] + in_examples = False + for line in help_text.splitlines(): + # Match example section headings: + # - "examples:" (default examples section) + # - "load examples:" (category headings with examples suffix) + # - "Field-scoped search:" (multi-word category headings) + # Exclude argparse sections like "positional arguments:", "options:" + stripped = line.strip() + is_section_heading = ( + stripped.endswith(":") + and stripped not in ("positional arguments:", "options:") + and not stripped.startswith("-") + ) + if is_section_heading: + in_examples = True + elif in_examples and line.startswith(" "): + cmd = line.strip() + if cmd.startswith("tmuxp"): + examples.append(cmd) + elif line and not line[0].isspace(): + in_examples = False + return examples + + +def test_main_help_has_examples() -> None: + """Main --help should have at least one example.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, "Main --help should have at least one example" + + +def test_main_help_examples_are_valid_subcommands() -> None: + """All examples in main --help should reference valid subcommands.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + + # Extract valid subcommands from help output + valid_subcommands = { + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + } + + for example in examples: + parts = example.split() + if len(parts) >= 2: + subcommand = parts[1] + assert subcommand in valid_subcommands, ( + f"Example '{example}' uses unknown subcommand '{subcommand}'" + ) + + +@pytest.mark.parametrize( + "subcommand", + [ + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + ], +) +def test_subcommand_help_has_examples(subcommand: str) -> None: + """Each subcommand --help should have at least one example.""" + help_text = _get_help_text(subcommand) + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, f"{subcommand} --help should have at least one example" + + +def test_load_subcommand_examples_are_valid() -> None: + """Load subcommand examples should have valid flags.""" + help_text = _get_help_text("load") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp load"), f"Bad example format: {example}" + + +def test_freeze_subcommand_examples_are_valid() -> None: + """Freeze subcommand examples should have valid flags.""" + help_text = _get_help_text("freeze") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp freeze"), f"Bad example format: {example}" + + +def test_shell_subcommand_examples_are_valid() -> None: + """Shell subcommand examples should have valid flags.""" + help_text = _get_help_text("shell") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp shell"), f"Bad example format: {example}" + + +def test_convert_subcommand_examples_are_valid() -> None: + """Convert subcommand examples should have valid flags.""" + help_text = _get_help_text("convert") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp convert"), f"Bad example format: {example}" + + +def test_import_subcommand_examples_are_valid() -> None: + """Import subcommand examples should have valid flags.""" + help_text = _get_help_text("import") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp import"), f"Bad example format: {example}" + + +def test_edit_subcommand_examples_are_valid() -> None: + """Edit subcommand examples should have valid flags.""" + help_text = _get_help_text("edit") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp edit"), f"Bad example format: {example}" + + +def test_ls_subcommand_examples_are_valid() -> None: + """Ls subcommand examples should have valid flags.""" + help_text = _get_help_text("ls") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp ls"), f"Bad example format: {example}" + + +def test_debug_info_subcommand_examples_are_valid() -> None: + """Debug-info subcommand examples should have valid flags.""" + help_text = _get_help_text("debug-info") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" + + +def test_search_subcommand_examples_are_valid() -> None: + """Search subcommand examples should have valid flags.""" + help_text = _get_help_text("search") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp search"), f"Bad example format: {example}" + + +def test_search_no_args_shows_help() -> None: + """Running 'tmuxp search' with no args shows help. + + Note: This test uses subprocess to verify actual CLI behavior and exit code. + """ + result = subprocess.run( + ["tmuxp", "search"], + capture_output=True, + text=True, + ) + # Should show help (usage line present) + assert "usage: tmuxp search" in result.stdout + # Should exit successfully (not error) + assert result.returncode == 0 + + +def test_main_help_example_sections_have_examples_suffix() -> None: + """Main --help should have section headings ending with 'examples:'.""" + help_text = _get_help_text() + + # Should have "load examples:", "freeze examples:", etc. + # NOT just "load:", "freeze:" + assert "load examples:" in help_text.lower() + assert "freeze examples:" in help_text.lower() + + +def test_main_help_examples_are_colorized(monkeypatch: pytest.MonkeyPatch) -> None: + """Main --help should have colorized example sections when FORCE_COLOR is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + + help_text = _get_help_text() + + # Should contain ANSI escape codes for colorization + assert "\033[" in help_text, "Example sections should be colorized" diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py new file mode 100644 index 0000000000..4faad8fe2c --- /dev/null +++ b/tests/cli/test_import.py @@ -0,0 +1,175 @@ +"""CLI tests for tmuxp import.""" + +from __future__ import annotations + +import contextlib +import io +import typing as t + +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp import cli + +if t.TYPE_CHECKING: + import pathlib + + +class ImportTestFixture(t.NamedTuple): + """Test fixture for basic tmuxp import command tests.""" + + test_id: str + cli_args: list[str] + + +IMPORT_TEST_FIXTURES: list[ImportTestFixture] = [ + ImportTestFixture( + test_id="basic_import", + cli_args=["import"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTestFixture._fields), + IMPORT_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TEST_FIXTURES], +) +def test_import( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Basic CLI test for tmuxp import.""" + cli.cli(cli_args) + result = capsys.readouterr() + assert "tmuxinator" in result.out + assert "teamocil" in result.out + + +class ImportTeamocilTestFixture(t.NamedTuple): + """Test fixture for tmuxp import teamocil command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +IMPORT_TEAMOCIL_TEST_FIXTURES: list[ImportTeamocilTestFixture] = [ + ImportTeamocilTestFixture( + test_id="import_teamocil_config_file", + cli_args=["import", "teamocil", "./.teamocil/config.yaml"], + inputs=["\n", "y\n", "./la.yaml\n", "y\n"], + ), + ImportTeamocilTestFixture( + test_id="import_teamocil_config_file_exists", + cli_args=["import", "teamocil", "./.teamocil/config.yaml"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + ImportTeamocilTestFixture( + test_id="import_teamocil_config_name", + cli_args=["import", "teamocil", "config"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTeamocilTestFixture._fields), + IMPORT_TEAMOCIL_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TEAMOCIL_TEST_FIXTURES], +) +def test_import_teamocil( + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test for tmuxp import w/ teamocil.""" + teamocil_config = test_utils.read_workspace_file("import_teamocil/test4.yaml") + + teamocil_path = tmp_path / ".teamocil" + teamocil_path.mkdir() + + teamocil_config_path = teamocil_path / "config.yaml" + teamocil_config_path.write_text(teamocil_config, encoding="utf-8") + + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + new_config_yaml = tmp_path / "la.yaml" + assert new_config_yaml.exists() + + +class ImportTmuxinatorTestFixture(t.NamedTuple): + """Test fixture for tmuxp import tmuxinator command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +IMPORT_TMUXINATOR_TEST_FIXTURES: list[ImportTmuxinatorTestFixture] = [ + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_file", + cli_args=["import", "tmuxinator", "./.tmuxinator/config.yaml"], + inputs=["\n", "y\n", "./la.yaml\n", "y\n"], + ), + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_file_exists", + cli_args=["import", "tmuxinator", "./.tmuxinator/config.yaml"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_name", + cli_args=["import", "tmuxinator", "config"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTmuxinatorTestFixture._fields), + IMPORT_TMUXINATOR_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TMUXINATOR_TEST_FIXTURES], +) +def test_import_tmuxinator( + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test for tmuxp import w/ tmuxinator.""" + tmuxinator_config = test_utils.read_workspace_file("import_tmuxinator/test3.yaml") + + tmuxinator_path = tmp_path / ".tmuxinator" + tmuxinator_path.mkdir() + + tmuxinator_config_path = tmuxinator_path / "config.yaml" + tmuxinator_config_path.write_text(tmuxinator_config, encoding="utf-8") + + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + new_config_yaml = tmp_path / "la.yaml" + assert new_config_yaml.exists() diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py new file mode 100644 index 0000000000..158e69b83f --- /dev/null +++ b/tests/cli/test_import_colors.py @@ -0,0 +1,143 @@ +"""Tests for CLI colors in import command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Import command color output tests + + +def test_import_error_unknown_format(colors_always: Colors) -> None: + """Verify unknown format error uses error color (red).""" + msg = "Unknown config format." + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_import_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_import_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_import_muted_for_banner(colors_always: Colors) -> None: + """Verify banner text uses muted color (blue).""" + msg = "Configuration import does its best to convert files." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_import_muted_for_separator(colors_always: Colors) -> None: + """Verify separator uses muted color (blue).""" + separator = "---------------------------------------------------------------" + result = colors_always.muted(separator) + assert ANSI_BLUE in result # blue foreground + assert separator in result + + +def test_import_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.muted("muted") == "muted" + assert colors_never.info("info") == "info" + + +def test_import_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_import_help_text_with_urls(colors_always: Colors) -> None: + """Verify help text uses muted for text and info for URLs.""" + url = "" + help_text = colors_always.muted( + "tmuxp has examples in JSON and YAML format at " + ) + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +def test_import_banner_with_separator(colors_always: Colors) -> None: + """Verify banner format with separator and instruction text.""" + config_content = "session_name: test\n" + separator = "---------------------------------------------------------------" + output = ( + config_content + + colors_always.muted(separator) + + "\n" + + colors_always.muted("Configuration import does its best to convert files.") + + "\n" + ) + # Should contain blue ANSI code for muted sections + assert ANSI_BLUE in output + assert separator in output + assert "Configuration import" in output + assert config_content in output + + +# Privacy masking tests + + +def test_import_masks_home_in_save_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in save prompt.""" + cwd = mock_home / "projects" + prompt = f"Save to [{PrivatePath(cwd)}]" + + assert "[~/projects]" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_confirm_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in confirmation prompt.""" + dest_path = mock_home / ".tmuxp/imported.yaml" + prompt = f"Save to {PrivatePath(dest_path)}?" + + assert "~/.tmuxp/imported.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Import should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/imported.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/imported.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py new file mode 100644 index 0000000000..275e798d68 --- /dev/null +++ b/tests/cli/test_load.py @@ -0,0 +1,993 @@ +"""CLI tests for tmuxp load.""" + +from __future__ import annotations + +import contextlib +import io +import pathlib +import typing as t + +import libtmux +import pytest +from libtmux.server import Server +from libtmux.session import Session + +from tests.constants import FIXTURE_PATH +from tests.fixtures import utils as test_utils +from tmuxp import cli +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli.load import ( + _load_append_windows_to_current_session, + _load_attached, + load_plugins, + load_workspace, +) +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_load_workspace( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generic test for loading a tmuxp workspace via tmuxp load.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_passes_tmux_config( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with a tmux configuration file.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + tmux_config_file=str(FIXTURE_PATH / "tmux" / "tmux.conf"), + detached=True, + ) + + assert isinstance(session, Session) + assert isinstance(session.server, Server) + assert session.server.config_file == str(FIXTURE_PATH / "tmux" / "tmux.conf") + + +def test_load_workspace_named_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with a custom tmux session name.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + new_session_name="tmuxp-new", + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "tmuxp-new" + + +def test_load_workspace_name_match_regression_252( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load for a regression where tmux shell names would not match.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + workspace_file = tmp_path / "simple.yaml" + + workspace_file.write_text( + """ +session_name: sampleconfi +start_directory: './' +windows: +- panes: + - echo 'hey'""", + encoding="utf-8", + ) + + # open it detached + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert session.name == "sampleconfi" + + +def test_load_symlinked_workspace( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load can follow a symlinked tmuxp config file.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + + realtemp = tmp_path / "myrealtemp" + realtemp.mkdir() + linktemp = tmp_path / "symlinktemp" + linktemp.symlink_to(realtemp) + workspace_file = linktemp / "simple.yaml" + + workspace_file.write_text( + """ +session_name: samplesimple +start_directory: './' +windows: +- panes: + - echo 'hey'""", + encoding="utf-8", + ) + + # open it detached + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert session.active_window is not None + pane = session.active_window.active_pane + + assert isinstance(session, Session) + assert session.name == "samplesimple" + + assert pane is not None + assert pane.pane_current_path == str(realtemp) + + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from pytest_mock import MockerFixture + + ExpectedOutput: TypeAlias = str | list[str] | None + + +class CLILoadFixture(t.NamedTuple): + """Test fixture for tmuxp load tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + cli_args: list[str | list[str]] + config_paths: list[str] + session_names: list[str] + expected_exit_code: int + expected_in_out: ExpectedOutput = None + expected_not_in_out: ExpectedOutput = None + expected_in_err: ExpectedOutput = None + expected_not_in_err: ExpectedOutput = None + + +TEST_LOAD_FIXTURES: list[CLILoadFixture] = [ + CLILoadFixture( + test_id="dir-relative-dot-samedir", + cli_args=["load", "."], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="dir-relative-dot-slash-samedir", + cli_args=["load", "./"], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="dir-relative-file-samedir", + cli_args=["load", "./.tmuxp.yaml"], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="filename-relative-file-samedir", + cli_args=["load", "./my_config.yaml"], + config_paths=["{tmp_path}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="configdir-session-name", + cli_args=["load", "my_config"], + config_paths=["{TMUXP_CONFIGDIR}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="configdir-absolute", + cli_args=["load", "~/.config/tmuxp/my_config.yaml"], + config_paths=["{TMUXP_CONFIGDIR}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + # + # Multiple configs + # + CLILoadFixture( + test_id="configdir-session-name-double", + cli_args=["load", "my_config", "second_config"], + config_paths=[ + "{TMUXP_CONFIGDIR}/my_config.yaml", + "{TMUXP_CONFIGDIR}/second_config.yaml", + ], + session_names=["my_config", "second_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), +] + + +@pytest.mark.parametrize( + list(CLILoadFixture._fields), + TEST_LOAD_FIXTURES, + ids=[test.test_id for test in TEST_LOAD_FIXTURES], +) +@pytest.mark.usefixtures("tmuxp_configdir_default") +def test_load( + tmp_path: pathlib.Path, + tmuxp_configdir: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + test_id: str, + cli_args: list[str], + config_paths: list[str], + session_names: list[str], + expected_exit_code: int, + expected_in_out: ExpectedOutput, + expected_not_in_out: ExpectedOutput, + expected_in_err: ExpectedOutput, + expected_not_in_err: ExpectedOutput, +) -> None: + """Parametrized test battery for tmuxp load CLI command.""" + assert server.socket_name is not None + + monkeypatch.chdir(tmp_path) + for session_name, config_path in zip(session_names, config_paths, strict=False): + tmuxp_config = pathlib.Path( + config_path.format(tmp_path=tmp_path, TMUXP_CONFIGDIR=tmuxp_configdir), + ) + tmuxp_config.write_text( + f""" + session_name: {session_name} + windows: + - window_name: test + panes: + - + """, + encoding="utf-8", + ) + + with contextlib.suppress(SystemExit): + cli.cli([*cli_args, "-d", "-L", server.socket_name, "-y"]) + + result = capsys.readouterr() + output = "".join(list(result.out)) + + if expected_in_out is not None: + if isinstance(expected_in_out, str): + expected_in_out = [expected_in_out] + for needle in expected_in_out: + assert needle in output + + if expected_not_in_out is not None: + if isinstance(expected_not_in_out, str): + expected_not_in_out = [expected_not_in_out] + for needle in expected_not_in_out: + assert needle not in output + + for session_name in session_names: + assert server.has_session(session_name) + + +def test_regression_00132_session_name_with_dots( + tmp_path: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], +) -> None: + """Regression test for session names with dots.""" + yaml_config = FIXTURE_PATH / "workspace/builder" / "regression_00132_dots.yaml" + cli_args = [str(yaml_config)] + with pytest.raises(libtmux.exc.BadSessionName): + cli.cli(["load", *cli_args]) + + +class ZshAutotitleTestFixture(t.NamedTuple): + """Test fixture for zsh auto title warning tests.""" + + test_id: str + cli_args: list[str] + + +ZSH_AUTOTITLE_TEST_FIXTURES: list[ZshAutotitleTestFixture] = [ + ZshAutotitleTestFixture( + test_id="load_dot_detached", + cli_args=["load", ".", "-d"], + ), + ZshAutotitleTestFixture( + test_id="load_yaml_detached", + cli_args=["load", ".tmuxp.yaml", "-d"], + ), +] + + +@pytest.mark.parametrize( + list(ZshAutotitleTestFixture._fields), + ZSH_AUTOTITLE_TEST_FIXTURES, + ids=[test.test_id for test in ZSH_AUTOTITLE_TEST_FIXTURES], +) +def test_load_zsh_autotitle_warning( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + server: Server, +) -> None: + """Test warning when ZSH auto title is enabled.""" + # create dummy tmuxp yaml so we don't get yelled at + yaml_config = tmp_path / ".tmuxp.yaml" + yaml_config.write_text( + """ + session_name: test + windows: + - window_name: test + panes: + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + monkeypatch.setenv("SHELL", "zsh") + + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out + + monkeypatch.setenv("DISABLE_AUTO_TITLE", "false") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out + + monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out + + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + monkeypatch.setenv("SHELL", "sh") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out + + +class LogFileTestFixture(t.NamedTuple): + """Test fixture for log file tests.""" + + test_id: str + cli_args: list[str] + + +LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ + LogFileTestFixture( + test_id="load_with_log_file", + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], + ), +] + + +@pytest.mark.parametrize( + list(LogFileTestFixture._fields), + LOG_FILE_TEST_FIXTURES, + ids=[test.test_id for test in LOG_FILE_TEST_FIXTURES], +) +def test_load_log_file( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test loading with a log file.""" + # create dummy tmuxp yaml that breaks to prevent actually loading tmux + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(cli_args) + + result = capsys.readouterr() + log_file_path = tmp_path / "log.txt" + assert "loading workspace" in log_file_path.open().read() + assert result.out is not None + + +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + +def test_load_plugins( + monkeypatch_plugin_test_packages: None, +) -> None: + """Test loading via tmuxp load with plugins.""" + from tmuxp_test_plugin_bwb.plugin import ( # type: ignore + PluginBeforeWorkspaceBuilder, + ) + + plugins_config = test_utils.read_workspace_file("workspace/builder/plugin_bwb.yaml") + + session_config = ConfigReader._load(fmt="yaml", content=plugins_config) + session_config = loader.expand(session_config) + + plugins = load_plugins(session_config) + + assert len(plugins) == 1 + + test_plugin_class_types = [ + PluginBeforeWorkspaceBuilder().__class__, + ] + for plugin in plugins: + assert plugin.__class__ in test_plugin_class_types + + +class PluginVersionTestFixture(t.NamedTuple): + """Test fixture for plugin version tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +PLUGIN_VERSION_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ + PluginVersionTestFixture( + test_id="skip_version_fail", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_versions_fail.yaml"], + inputs=["y\n"], + ), +] + + +@pytest.mark.skip("Not sure how to clean up the tmux session this makes") +@pytest.mark.parametrize( + list(PluginVersionTestFixture._fields), + PLUGIN_VERSION_SKIP_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_VERSION_SKIP_TEST_FIXTURES], +) +def test_load_plugins_version_fail_skip( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + inputs: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + """Test plugin version failure with skip.""" + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Loading]" in result.out + + +PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ + PluginVersionTestFixture( + test_id="no_skip_version_fail", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_versions_fail.yaml"], + inputs=["n\n"], + ), +] + + +@pytest.mark.parametrize( + list(PluginVersionTestFixture._fields), + PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES], +) +def test_load_plugins_version_fail_no_skip( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + inputs: list[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test plugin version failure without skip.""" + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Not Skipping]" in result.out + + +class PluginMissingTestFixture(t.NamedTuple): + """Test fixture for plugin missing tests.""" + + test_id: str + cli_args: list[str] + + +PLUGIN_MISSING_TEST_FIXTURES: list[PluginMissingTestFixture] = [ + PluginMissingTestFixture( + test_id="missing_plugin", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_missing_fail.yaml"], + ), +] + + +@pytest.mark.parametrize( + list(PluginMissingTestFixture._fields), + PLUGIN_MISSING_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_MISSING_TEST_FIXTURES], +) +def test_load_plugins_plugin_missing( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + """Test loading with missing plugin.""" + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Plugin Error]" in result.out + + +class WorkspaceBuilderErrorFixture(t.NamedTuple): + """Test fixture for invalid ``workspace_builder`` selections.""" + + test_id: str + yaml: str + expected_substring: str + + +WORKSPACE_BUILDER_ERROR_FIXTURES: list[WorkspaceBuilderErrorFixture] = [ + WorkspaceBuilderErrorFixture( + test_id="unknown_entry_point_name", + yaml="""\ +session_name: builder-error +workspace_builder: nonexistent-builder +windows: +- panes: + - echo hi +""", + expected_substring="could not be found", + ), + WorkspaceBuilderErrorFixture( + test_id="dotted_import_error", + yaml="""\ +session_name: builder-error +workspace_builder: tmuxp.does_not_exist:Thing +windows: +- panes: + - echo hi +""", + expected_substring="Could not import", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_object", + yaml="""\ +session_name: builder-error +workspace_builder: tests.fixtures.workspace_builders.invalid:NotABuilder +windows: +- panes: + - echo hi +""", + expected_substring="not a valid workspace builder", + ), + WorkspaceBuilderErrorFixture( + test_id="missing_builder_path", + yaml="""\ +session_name: builder-error +workspace_builder: any:Builder +workspace_builder_paths: +- /nonexistent/tmuxp/builder/dir +windows: +- panes: + - echo hi +""", + expected_substring="workspace_builder_paths entry is invalid", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_pane_readiness", + yaml="""\ +session_name: builder-error +workspace_builder_options: + pane_readiness: sometimes +windows: +- panes: + - echo hi +""", + expected_substring="pane_readiness", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceBuilderErrorFixture._fields), + WORKSPACE_BUILDER_ERROR_FIXTURES, + ids=[test.test_id for test in WORKSPACE_BUILDER_ERROR_FIXTURES], +) +def test_load_workspace_builder_error_exits( + tmp_path: pathlib.Path, + server: Server, + capsys: pytest.CaptureFixture[str], + test_id: str, + yaml: str, + expected_substring: str, +) -> None: + """Invalid workspace_builder exits with a styled error, not a traceback.""" + config_file = tmp_path / ".tmuxp.yaml" + config_file.write_text(yaml, encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + load_workspace(config_file, socket_name=server.socket_name, detached=True) + + assert excinfo.value.code == 1 + assert expected_substring in capsys.readouterr().out + + +def test_plugin_system_before_script( + monkeypatch_plugin_test_packages: None, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with sessions using before_script.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "plugin_bs.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "plugin_test_bs" + + +def test_load_attached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test tmuxp load's attachment behavior.""" + # Load a session and attach from outside tmux + monkeypatch.delenv("TMUX", raising=False) + + attach_mock = mocker.patch("libtmux.session.Session.attach") + attach_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, False) + + assert attach_mock.call_count == 1 + + +def test_load_attached_detached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test tmuxp load when sessions are build without attaching client.""" + # Load a session but don't attach + monkeypatch.delenv("TMUX", raising=False) + + attach_mock = mocker.patch("libtmux.session.Session.attach") + attach_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, True) + + assert attach_mock.call_count == 0 + + +def test_load_attached_within_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test loading via tmuxp load when already within a tmux session.""" + # Load a session and attach from within tmux + monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") + + switch_client_mock = mocker.patch("libtmux.session.Session.switch_client") + switch_client_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, False) + + assert switch_client_mock.call_count == 1 + + +def test_load_attached_within_tmux_detached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test loading via tmuxp load within a tmux session switches clients.""" + # Load a session and attach from within tmux + monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") + + switch_client_mock = mocker.patch("libtmux.session.Session.switch_client") + switch_client_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, True) + + assert switch_client_mock.call_count == 1 + + +def test_load_append_windows_to_current_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load when windows are appended to the current session.""" + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.windows) == 3 + + # Assign an active pane to the session + assert server.panes[0].pane_id + monkeypatch.setenv("TMUX_PANE", server.panes[0].pane_id) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + _load_append_windows_to_current_session(builder) + + assert len(server.sessions) == 1 + assert len(server.windows) == 6 + + +# Privacy masking in load command + + +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +class ProgressDisableFixture(t.NamedTuple): + """Test fixture for progress disable logic.""" + + test_id: str + env_value: str | None + no_progress_flag: bool + expected_disabled: bool + + +PROGRESS_DISABLE_FIXTURES: list[ProgressDisableFixture] = [ + ProgressDisableFixture("default_enabled", None, False, False), + ProgressDisableFixture("env_disabled", "0", False, True), + ProgressDisableFixture("flag_disabled", None, True, True), + ProgressDisableFixture("env_enabled_explicit", "1", False, False), + ProgressDisableFixture("flag_overrides_env", "1", True, True), +] + + +@pytest.mark.parametrize( + list(ProgressDisableFixture._fields), + PROGRESS_DISABLE_FIXTURES, + ids=[f.test_id for f in PROGRESS_DISABLE_FIXTURES], +) +def test_progress_disable_logic( + test_id: str, + env_value: str | None, + no_progress_flag: bool, + expected_disabled: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress disable expression matches expected behavior.""" + if env_value is not None: + monkeypatch.setenv("TMUXP_PROGRESS", env_value) + else: + monkeypatch.delenv("TMUXP_PROGRESS", raising=False) + + import os + + result = no_progress_flag or os.getenv("TMUXP_PROGRESS", "1") == "0" + assert result is expected_disabled + + +def test_load_workspace_no_progress( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with no_progress=True creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + no_progress=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_env_progress_disabled( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with TMUXP_PROGRESS=0 creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUXP_PROGRESS", "0") + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" + + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py new file mode 100644 index 0000000000..e6f64e28fc --- /dev/null +++ b/tests/cli/test_ls.py @@ -0,0 +1,746 @@ +"""CLI tests for tmuxp ls command.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib + +import pytest + +from tmuxp import cli +from tmuxp.cli.ls import ( + _get_workspace_info, + create_ls_subparser, +) + + +def test_get_workspace_info_yaml(tmp_path: pathlib.Path) -> None: + """Extract metadata from YAML workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: my-session\nwindows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "yaml" + assert info["session_name"] == "my-session" + assert info["size"] > 0 + assert "T" in info["mtime"] # ISO format contains T + assert info["source"] == "global" # Default source + + +def test_get_workspace_info_source_local(tmp_path: pathlib.Path) -> None: + """Extract metadata with source=local.""" + workspace = tmp_path / ".tmuxp.yaml" + workspace.write_text("session_name: local-session\nwindows: []") + + info = _get_workspace_info(workspace, source="local") + + assert info["name"] == ".tmuxp" + assert info["source"] == "local" + assert info["session_name"] == "local-session" + + +def test_get_workspace_info_json(tmp_path: pathlib.Path) -> None: + """Extract metadata from JSON workspace file.""" + workspace = tmp_path / "test.json" + workspace.write_text('{"session_name": "json-session", "windows": []}') + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "json" + assert info["session_name"] == "json-session" + + +def test_get_workspace_info_no_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None + + +def test_get_workspace_info_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None # Couldn't parse, so None + + +def test_ls_subparser_adds_tree_flag() -> None: + """Verify --tree argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--tree"]) + + assert args.tree is True + + +def test_ls_subparser_adds_json_flag() -> None: + """Verify --json argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--json"]) + + assert args.output_json is True + + +def test_ls_subparser_adds_ndjson_flag() -> None: + """Verify --ndjson argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--ndjson"]) + + assert args.output_ndjson is True + + +def test_ls_cli( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls.""" + filenames = [ + ".git/", + ".gitignore/", + "session_1.yaml", + "session_2.yaml", + "session_3.json", + "session_4.txt", + ] + + # should ignore: + # - directories should be ignored + # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] + stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] + + for filename in filenames: + location = isolated_home / f".tmuxp/{filename}" + if filename.endswith("/"): + location.mkdir(parents=True) + else: + location.touch() + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + cli_output = capsys.readouterr().out + + # Output now has headers with directory path, check for workspace names + assert "Global workspaces (~/.tmuxp):" in cli_output + for stem in stems: + assert stem in cli_output + + +def test_ls_json_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --json.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + workspaces = data["workspaces"] + assert len(workspaces) == 2 + + names = {item["name"] for item in workspaces} + assert names == {"dev", "prod"} + + # Verify all expected fields are present + for item in workspaces: + assert "name" in item + assert "path" in item + assert "format" in item + assert "size" in item + assert "mtime" in item + assert "session_name" in item + assert "source" in item + assert item["source"] == "global" + + +def test_ls_ndjson_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --ndjson.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") + (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--ndjson"]) + + output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # Each line should be valid JSON + for line in lines: + data = json.loads(line) + assert "name" in data + assert "session_name" in data + assert "source" in data + + +def test_ls_tree_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --tree.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + # Tree mode shows directory header + assert "~/.tmuxp" in output + # And indented workspace name + assert "dev" in output + + +def test_ls_empty_directory( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls with no workspaces.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "No workspaces found" in output + + +def test_ls_tree_shows_session_name_if_different( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode shows session_name if it differs from file name.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + # File named "myfile" but session is "actual-session" + (tmuxp_dir / "myfile.yaml").write_text("session_name: actual-session\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "myfile" in output + assert "actual-session" in output + + +def test_ls_finds_local_workspace_in_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in current directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_finds_local_workspace_in_parent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in parent directory.""" + home = tmp_path / "home" + project = home / "project" + subdir = project / "src" / "module" + subdir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(subdir) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_shows_local_and_global( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should show both local and global workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Local workspace + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + # Global workspace + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output + assert ".tmuxp" in output + assert "global" in output + + +def test_ls_json_includes_source_for_local( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should include source=local for local workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + sources = {item["source"] for item in workspaces} + assert sources == {"local", "global"} + + local_items = [item for item in workspaces if item["source"] == "local"] + global_items = [item for item in workspaces if item["source"] == "global"] + + assert len(local_items) == 1 + assert len(global_items) == 1 + assert local_items[0]["session_name"] == "local" + assert global_items[0]["session_name"] == "global" + + +def test_ls_local_shows_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Local workspaces should show their path in flat mode.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + # Local workspace output shows path (with ~ contraction) + assert "~/project/.tmuxp.yaml" in output + + +def test_ls_full_flag_subparser() -> None: + """Verify --full argument is added to subparser.""" + import argparse + + from tmuxp.cli.ls import create_ls_subparser + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--full"]) + + assert args.full is True + + +def test_get_workspace_info_include_config(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info with include_config=True.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") + + info = _get_workspace_info(workspace, include_config=True) + + assert "config" in info + assert info["config"]["session_name"] == "test" + assert len(info["config"]["windows"]) == 1 + + +def test_get_workspace_info_no_config_by_default(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info without include_config doesn't include config.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows: []\n") + + info = _get_workspace_info(workspace) + + assert "config" not in info + + +def test_ls_json_full_includes_config( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with --full includes config content.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json", "--full"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + assert len(workspaces) == 1 + assert "config" in workspaces[0] + assert workspaces[0]["config"]["session_name"] == "dev" + assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" + + +def test_ls_full_tree_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " layout: main-horizontal\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree", "--full"]) + + output = capsys.readouterr().out + + assert "dev" in output + assert "editor" in output + assert "main-horizontal" in output + assert "shell" in output + assert "pane 0" in output + + +def test_ls_full_flat_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Flat mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: code\n panes:\n - nvim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + assert "Global workspaces (~/.tmuxp):" in output + assert "dev" in output + assert "code" in output + assert "pane 0" in output + + +def test_ls_full_without_json_no_config_in_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Non-JSON with --full shows tree but not raw config.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: editor\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + # Should show tree structure, not raw config keys + assert "editor" in output + assert "session_name:" not in output # Raw YAML not in output + + +def test_ls_shows_global_workspace_dirs_section( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Human output shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "1 workspace" in output + assert "active" in output + assert "~/.config/tmuxp" in output + assert "not found" in output + + +def test_ls_global_header_shows_active_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Global workspaces header shows active directory path.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should include the active directory + assert "Global workspaces (~/.tmuxp):" in output + + +def test_ls_json_includes_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output includes global_workspace_dirs array.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON should be an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + # Check global_workspace_dirs structure + dirs = data["global_workspace_dirs"] + assert isinstance(dirs, list) + assert len(dirs) >= 1 + + for d in dirs: + assert "path" in d + assert "source" in d + assert "exists" in d + assert "workspace_count" in d + assert "active" in d + + # Find the active one + active_dirs = [d for d in dirs if d["active"]] + assert len(active_dirs) == 1 + assert active_dirs[0]["path"] == "~/.tmuxp" + assert active_dirs[0]["exists"] is True + assert active_dirs[0]["workspace_count"] == 1 + + +def test_ls_json_empty_still_has_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with no workspaces still includes global_workspace_dirs.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) # Empty directory + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert "workspaces" in data + assert "global_workspace_dirs" in data + assert len(data["workspaces"]) == 0 + assert len(data["global_workspace_dirs"]) >= 1 + + +def test_ls_xdg_takes_precedence_in_header( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" + home = tmp_path / "home" + xdg_tmuxp = home / ".config" / "tmuxp" + xdg_tmuxp.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should show XDG path when it's active + assert "Global workspaces (~/.config/tmuxp):" in output + + +def test_ls_tree_shows_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode also shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "active" in output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 0000000000..84bee97aa0 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,290 @@ +"""Tests for output formatting utilities.""" + +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from tmuxp.cli._output import OutputFormatter, OutputMode, get_output_mode + + +def test_output_mode_values() -> None: + """Verify OutputMode enum values.""" + assert OutputMode.HUMAN.value == "human" + assert OutputMode.JSON.value == "json" + assert OutputMode.NDJSON.value == "ndjson" + + +def test_output_mode_members() -> None: + """Verify all expected members exist.""" + members = list(OutputMode) + assert len(members) == 3 + assert OutputMode.HUMAN in members + assert OutputMode.JSON in members + assert OutputMode.NDJSON in members + + +def test_get_output_mode_default_is_human() -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN + + +def test_get_output_mode_json_flag() -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON + + +def test_get_output_mode_ndjson_flag() -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON + + +def test_get_output_mode_ndjson_takes_precedence() -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON + + +def test_output_formatter_default_mode_is_human() -> None: + """Default mode should be HUMAN.""" + formatter = OutputFormatter() + assert formatter.mode == OutputMode.HUMAN + + +def test_output_formatter_explicit_mode() -> None: + """Mode can be set explicitly.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter.mode == OutputMode.JSON + + +def test_output_formatter_json_buffer_initially_empty() -> None: + """JSON buffer should start empty.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter._json_buffer == [] + + +def test_emit_json_buffers_data() -> None: + """JSON mode should buffer data.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + assert len(formatter._json_buffer) == 2 + assert formatter._json_buffer[0] == {"name": "test1"} + assert formatter._json_buffer[1] == {"name": "test2"} + + +def test_emit_human_does_nothing() -> None: + """HUMAN mode emit should not buffer or output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit({"name": "test"}) + assert formatter._json_buffer == [] + + +def test_emit_ndjson_writes_immediately(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should write one JSON object per line immediately.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit({"name": "test1", "value": 42}) + formatter.emit({"name": "test2", "value": 43}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"name": "test1", "value": 42} + assert json.loads(lines[1]) == {"name": "test2", "value": 43} + + +def test_emit_text_human_outputs(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode should output text.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "Hello, world!\n" + + +def test_emit_text_json_silent(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_text_ndjson_silent(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_json_outputs_array(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode finalize should output formatted array.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0] == {"name": "test1"} + assert data[1] == {"name": "test2"} + + +def test_finalize_json_clears_buffer() -> None: + """JSON mode finalize should clear the buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test"}) + assert len(formatter._json_buffer) == 1 + + # Capture output to prevent test pollution + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.finalize() + finally: + sys.stdout = old_stdout + + assert formatter._json_buffer == [] + + +def test_finalize_json_empty_buffer_no_output( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode finalize with empty buffer should not output.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_human_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode finalize should do nothing.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_ndjson_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode finalize should do nothing (already streamed).""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_json_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete JSON output workflow.""" + formatter = OutputFormatter(OutputMode.JSON) + + # Emit several records + formatter.emit({"name": "workspace1", "path": "/path/1"}) + formatter.emit({"name": "workspace2", "path": "/path/2"}) + + # Nothing output yet + captured = capsys.readouterr() + assert captured.out == "" + + # Finalize outputs everything + formatter.finalize() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + + +def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete NDJSON output workflow.""" + formatter = OutputFormatter(OutputMode.NDJSON) + + # Each emit outputs immediately + formatter.emit({"name": "workspace1"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace1"} + + formatter.emit({"name": "workspace2"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace2"} + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + +def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # emit does nothing in human mode + formatter.emit({"name": "ignored"}) + + # emit_text outputs text + formatter.emit_text("Workspace: test") + formatter.emit_text(" Path: /path/to/test") + + captured = capsys.readouterr() + assert "Workspace: test" in captured.out + assert "Path: /path/to/test" in captured.out + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py new file mode 100644 index 0000000000..8ace187b65 --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,1425 @@ +"""Tests for tmuxp CLI progress indicator.""" + +from __future__ import annotations + +import atexit +import io +import pathlib +import time +import typing as t + +import libtmux +import pytest + +from tmuxp.cli._colors import ColorMode +from tmuxp.cli._progress import ( + BAR_WIDTH, + ERASE_LINE, + HIDE_CURSOR, + PROGRESS_PRESETS, + SHOW_CURSOR, + SUCCESS_TEMPLATE, + BuildTree, + Spinner, + _truncate_visible, + _visible_len, + render_bar, + resolve_progress_format, +) + + +class SpinnerEnablementFixture(t.NamedTuple): + """Test fixture for spinner TTY/color enablement matrix.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_enabled: bool + + +SPINNER_ENABLEMENT_FIXTURES: list[SpinnerEnablementFixture] = [ + SpinnerEnablementFixture("tty_color_always", True, ColorMode.ALWAYS, True), + SpinnerEnablementFixture("tty_color_auto", True, ColorMode.AUTO, True), + SpinnerEnablementFixture("tty_color_never", True, ColorMode.NEVER, True), + SpinnerEnablementFixture("non_tty_color_always", False, ColorMode.ALWAYS, False), + SpinnerEnablementFixture("non_tty_color_never", False, ColorMode.NEVER, False), +] + + +@pytest.mark.parametrize( + list(SpinnerEnablementFixture._fields), + SPINNER_ENABLEMENT_FIXTURES, + ids=[f.test_id for f in SPINNER_ENABLEMENT_FIXTURES], +) +def test_spinner_enablement( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_enabled: bool, +) -> None: + """Spinner._enabled depends only on TTY, not on color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + assert spinner._enabled is expected_enabled + + +def test_spinner_disabled_output() -> None: + """Disabled spinner produces no output.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + with Spinner(message="Test", stream=stream) as spinner: + spinner.update_message("Updated") + + assert stream.getvalue() == "" + + +def test_spinner_enabled_output() -> None: + """Enabled spinner writes ANSI control sequences.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Test", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ): + pass # enter and exit — enough for at least one frame + cleanup + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert ERASE_LINE in output + assert "Test" in output + + +def test_spinner_atexit_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """atexit.register called on start, unregistered on stop.""" + registered: list[t.Any] = [] + unregistered: list[t.Any] = [] + monkeypatch.setattr(atexit, "register", lambda fn, *a: registered.append(fn)) + monkeypatch.setattr(atexit, "unregister", lambda fn: unregistered.append(fn)) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream) as spinner: + assert len(registered) == 1 + assert spinner._restore_cursor in registered + + assert len(unregistered) == 1 + assert spinner._restore_cursor in unregistered + + +def test_spinner_cleans_up_on_exception() -> None: + """SHOW_CURSOR written even when body raises.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + msg = "deliberate" + with ( + pytest.raises(ValueError), + Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream), + ): + raise ValueError(msg) + + assert SHOW_CURSOR in stream.getvalue() + + +def test_spinner_update_message_thread_safe() -> None: + """update_message() can be called from the main thread without error.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Start", color_mode=ColorMode.NEVER, stream=stream) + spinner.update_message("Updated") + assert spinner.message == "Updated" + + +def test_spinner_add_output_line_accumulates() -> None: + """add_output_line() appends stripped lines to the panel deque on TTY.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + spinner.add_output_line("Session created: test\n") + spinner.add_output_line("Creating window: editor") + spinner.add_output_line("") # blank lines are ignored + + assert list(spinner._output_lines) == [ + "Session created: test", + "Creating window: editor", + ] + + +def test_spinner_panel_respects_maxlen() -> None: + """Panel deque enforces output_lines maxlen, dropping oldest lines.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", color_mode=ColorMode.NEVER, stream=stream, output_lines=3 + ) + for i in range(5): + spinner.add_output_line(f"line {i}") + + panel = list(spinner._output_lines) + assert len(panel) == 3 + assert panel == ["line 2", "line 3", "line 4"] + + +def test_spinner_panel_rendered_in_output() -> None: + """Enabled spinner writes panel lines and spinner line to stream.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Building...", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ) as spinner: + spinner.add_output_line("Session created: my-session") + # Wait long enough for the spinner thread to render at least one frame + # that includes the panel line (interval=0.01s, so 0.05s is sufficient). + time.sleep(0.05) + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert "Session created: my-session" in output + assert "Building..." in output + + +# BuildTree tests + + +def test_build_tree_empty_renders_nothing() -> None: + """BuildTree.render() returns [] before any session_created event.""" + colors = ColorMode.NEVER + tree = BuildTree() + from tmuxp.cli._colors import Colors + + assert tree.render(Colors(colors), 80) == [] + + +def test_build_tree_session_created_shows_header() -> None: + """After session_created, render() returns the 'Session' heading line.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines == ["Session"] + + +def test_build_tree_window_started_no_pane_yet() -> None: + """window_started adds a window line with just the name (no pane info).""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert len(lines) == 2 + assert lines[1] == "- editor" + + +def test_build_tree_pane_creating_shows_progress() -> None: + """pane_creating updates the last window to show pane N of M.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 3}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- editor, pane (2 of 3)" + + +def test_build_tree_window_done_shows_checkmark() -> None: + """window_done marks the window as done; render shows checkmark.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + tree.on_event({"event": "window_done"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + + +def test_build_tree_workspace_built_marks_all_done() -> None: + """workspace_built marks all windows as done.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "window_started", "name": "logs", "pane_total": 1}) + tree.on_event({"event": "workspace_built"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- ✓ logs" + + +def test_build_tree_multiple_windows_accumulate() -> None: + """Multiple window_started events accumulate into separate tree lines.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "logging", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- logging, pane (1 of 1)" + + +def test_spinner_on_build_event_delegates_to_tree() -> None: + """Spinner.on_build_event() updates the internal BuildTree state.""" + import io + + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Building...", color_mode=ColorMode.NEVER, stream=stream) + spinner.on_build_event({"event": "session_created", "name": "test-session"}) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 1} + ) + + assert spinner._build_tree.session_name == "test-session" + assert len(spinner._build_tree.windows) == 1 + assert spinner._build_tree.windows[0].name == "editor" + + +# BuildTree.format_inline tests + + +def test_build_tree_format_inline_empty() -> None: + """format_inline returns base unchanged when no session has been created.""" + tree = BuildTree() + assert tree.format_inline("Building projects...") == "Building projects..." + + +def test_build_tree_format_inline_session_only() -> None: + """format_inline returns 'base session' after session_created with no windows.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_inline("Building projects...") == "Building projects... cihai" + + +def test_build_tree_format_inline_with_window_total() -> None: + """format_inline shows window index/total bracket after window_started.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows] gp-libs" + + +def test_build_tree_format_inline_with_panes() -> None: + """format_inline includes pane progress once pane_creating fires.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs" + + +def test_build_tree_format_inline_no_window_total() -> None: + """format_inline omits window count bracket when window_total is absent.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai"}) + tree.on_event({"event": "window_started", "name": "main", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + result = tree.format_inline("Building...") + assert result == "Building... cihai [1 of 1 panes] main" + + +def test_spinner_on_build_event_updates_message() -> None: + """on_build_event updates spinner.message via format_inline after each event.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + assert spinner.message == "Building... cihai [1 of 2 windows, 2 of 3 panes] editor" + + +# resolve_progress_format tests + + +def test_resolve_progress_format_preset_name() -> None: + """A known preset name resolves to its format string.""" + assert resolve_progress_format("default") == PROGRESS_PRESETS["default"] + assert resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + assert resolve_progress_format("verbose") == PROGRESS_PRESETS["verbose"] + + +def test_resolve_progress_format_raw_string() -> None: + """A raw template string is returned unchanged.""" + raw = "{session} w{window_progress}" + assert resolve_progress_format(raw) == raw + + +def test_resolve_progress_format_unknown_name() -> None: + """An unknown name not in presets is returned as-is (raw template pass-through).""" + assert resolve_progress_format("not-a-preset") == "not-a-preset" + + +# BuildTree.format_template tests + + +def test_build_tree_format_template_before_session() -> None: + """format_template returns '' before session_created fires.""" + tree = BuildTree() + assert tree.format_template("{session} [{progress}] {window}") == "" + + +def test_build_tree_format_template_session_only() -> None: + """After session_created alone, progress and window are empty.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_template("{session} [{progress}] {window}") == "cihai [] " + + +def test_build_tree_format_template_with_window() -> None: + """After window_started, window progress appears but pane progress does not.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win] editor" + ) + + +def test_build_tree_format_template_with_pane() -> None: + """After pane_creating, both window and pane progress appear.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win · 2/4 pane] editor" + ) + + +def test_build_tree_format_template_minimal() -> None: + """The minimal preset-style template shows only window fraction.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert tree.format_template("{session} [{window_progress}]") == "cihai [1/3]" + + +def test_build_tree_format_template_verbose() -> None: + """Verbose template shows window/pane indices and totals explicitly.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 12}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + result = tree.format_template(PROGRESS_PRESETS["verbose"]) + assert result == "Loading workspace: cihai [window 1 of 12 · pane 2 of 4] editor" + + +def test_build_tree_format_template_bad_token() -> None: + """Unknown tokens are left as {name}, known tokens still resolve.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown}") + # _SafeFormatMap: {session} resolves, {unknown} stays as-is + assert result == "cihai {unknown}" + + +# Spinner.progress_format integration tests + + +def test_spinner_progress_format_updates_message() -> None: + """Spinner with explicit progress_format uses format_template for updates.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + # Use an explicit format string rather than "default" preset to avoid + # coupling this test to the preset definition (which now includes {bar}). + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} [{progress}] {window}", + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 3} + ) + # No windows yet — falls back to base message to avoid showing empty brackets. + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 4} + ) + assert spinner.message == "cihai [1/3 win] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert spinner.message == "cihai [1/3 win · 2/4 pane] editor" + + +def test_spinner_progress_format_none_uses_inline() -> None: + """Spinner with progress_format=None preserves the format_inline path.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + +# render_bar tests + + +def test_render_bar_empty() -> None: + """render_bar with done=0 produces an all-empty bar.""" + assert render_bar(0, 10) == "░░░░░░░░░░" + + +def test_render_bar_half() -> None: + """render_bar with done=5, total=10 fills exactly half.""" + assert render_bar(5, 10) == "█████░░░░░" + + +def test_render_bar_full() -> None: + """render_bar with done=total fills the entire bar.""" + assert render_bar(10, 10) == "██████████" + + +def test_render_bar_zero_total() -> None: + """render_bar with total=0 returns empty string.""" + assert render_bar(0, 0) == "" + + +def test_render_bar_custom_width() -> None: + """render_bar with custom width produces bar of that inner width.""" + assert render_bar(3, 10, width=5) == "█░░░░" + + +def test_render_bar_width_constant() -> None: + """BAR_WIDTH is the default inner width used by render_bar.""" + bar = render_bar(0, 10) + assert len(bar) == BAR_WIDTH + + +# BuildTree new token tests + + +def test_build_tree_context_session_pane_total() -> None: + """session_pane_total token reflects count from session_created event.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + ctx = tree._context() + assert ctx["session_pane_total"] == 8 + assert ctx["session_pane_progress"] == "0/8" + assert ctx["overall_percent"] == 0 + + +def test_build_tree_context_window_progress_rel() -> None: + """window_progress_rel is 0/N from session_created, increments on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 2}) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_done"}) + assert tree._context()["window_progress_rel"] == "1/3" + + +def test_build_tree_context_pane_progress_rel() -> None: + """pane_progress_rel shows 0/M after window_started, N/M after pane_creating.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 4, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "0/4" + + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "2/4" + assert tree._context()["pane_done"] == 2 + assert tree._context()["pane_remaining"] == 2 + + +def test_build_tree_context_overall_percent() -> None: + """overall_percent is pane-based 0-100; updates on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + assert tree._context()["overall_percent"] == 0 + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + tree.on_event({"event": "window_done"}) + assert tree._context()["session_panes_done"] == 4 + assert tree._context()["overall_percent"] == 50 + + +def test_build_tree_before_script_event_toggle() -> None: + """before_script_started sets the Event; before_script_done clears it.""" + tree = BuildTree() + assert not tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_started"}) + assert tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_done"}) + assert not tree._before_script_event.is_set() + + +def test_build_tree_zero_pane_window() -> None: + """Windows with pane_total=0 do not cause division-by-zero or exceptions.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + + assert tree.session_panes_done == 0 + assert tree.windows_done == 1 + ctx = tree._context() + assert ctx["session_pane_progress"] == "" + assert ctx["overall_percent"] == 0 + + +def test_format_template_extra_backward_compat() -> None: + """format_template(fmt) without extra still works as before.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} [{progress}] {window}") + assert result == "cihai [] " + + +def test_format_template_extra_injected() -> None: + """format_template resolves extra tokens from the extra dict.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {bar}", extra={"bar": "[TEST_BAR]"}) + assert result == "cihai [TEST_BAR]" + + +def test_format_template_unknown_token_preserved() -> None: + """Unknown tokens in the format string render as {name}, not blank or raw fmt.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown_token}") + assert result == "cihai {unknown_token}" + + +# Spinner bar token tests + + +def test_spinner_bar_token_no_color() -> None: + """With ColorMode.NEVER, {bar} token in message contains bar characters.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} {bar} {progress} {window}", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "cihai", + "window_total": 3, + "session_pane_total": 6, + } + ) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 2} + ) + spinner.on_build_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_pane_bar_preset() -> None: + """The 'pane' preset wires {pane_bar} and {session_pane_progress}.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="pane", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 4, + } + ) + spinner.on_build_event({"event": "window_started", "name": "w1", "pane_total": 2}) + spinner.on_build_event({"event": "window_done"}) + + assert "2/4" in spinner.message + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_before_script_event_via_events() -> None: + """before_script_started / before_script_done toggle the BuildTree Event flag.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event({"event": "before_script_started"}) + assert spinner._build_tree._before_script_event.is_set() + + spinner.on_build_event({"event": "before_script_done"}) + assert not spinner._build_tree._before_script_event.is_set() + + +def test_progress_presets_have_expected_keys() -> None: + """All expected preset names are present in PROGRESS_PRESETS.""" + for name in ("default", "minimal", "window", "pane", "verbose"): + assert name in PROGRESS_PRESETS, f"Missing preset: {name}" + + +def test_progress_presets_default_includes_bar() -> None: + """The 'default' preset includes the {bar} token.""" + assert "{bar}" in PROGRESS_PRESETS["default"] + + +def test_progress_presets_minimal_format() -> None: + """The 'minimal' preset includes the Loading prefix and window_progress token.""" + expected = "Loading workspace: {session} [{window_progress}]" + assert PROGRESS_PRESETS["minimal"] == expected + + +# BuildTree remaining token tests + + +class RemainingTokenFixture(t.NamedTuple): + """Test fixture for windows_remaining and session_panes_remaining tokens.""" + + test_id: str + events: list[dict[str, t.Any]] + token: str + expected: int + + +REMAINING_TOKEN_FIXTURES: list[RemainingTokenFixture] = [ + RemainingTokenFixture( + "windows_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + ], + "windows_remaining", + 3, + ), + RemainingTokenFixture( + "windows_remaining_after_done", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_done"}, + ], + "windows_remaining", + 2, + ), + RemainingTokenFixture( + "session_panes_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + ], + "session_panes_remaining", + 5, + ), + RemainingTokenFixture( + "session_panes_remaining_after_window", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + {"event": "window_started", "name": "w1", "pane_total": 3}, + {"event": "window_done"}, + ], + "session_panes_remaining", + 2, + ), +] + + +@pytest.mark.parametrize( + list(RemainingTokenFixture._fields), + REMAINING_TOKEN_FIXTURES, + ids=[f.test_id for f in REMAINING_TOKEN_FIXTURES], +) +def test_build_tree_remaining_tokens( + test_id: str, + events: list[dict[str, t.Any]], + token: str, + expected: int, +) -> None: + """Remaining tokens decrement correctly as windows/panes complete.""" + tree = BuildTree() + for ev in events: + tree.on_event(ev) + assert tree._context()[token] == expected + + +# _visible_len tests + + +class VisibleLenFixture(t.NamedTuple): + """Test fixture for _visible_len ANSI-aware length calculation.""" + + test_id: str + text: str + expected_len: int + + +VISIBLE_LEN_FIXTURES: list[VisibleLenFixture] = [ + VisibleLenFixture("plain_text", "hello", 5), + VisibleLenFixture("ansi_green", "\033[32mgreen\033[0m", 5), + VisibleLenFixture("empty_string", "", 0), + VisibleLenFixture("nested_ansi", "\033[1m\033[31mbold red\033[0m", 8), + VisibleLenFixture("ansi_only", "\033[0m", 0), +] + + +@pytest.mark.parametrize( + list(VisibleLenFixture._fields), + VISIBLE_LEN_FIXTURES, + ids=[f.test_id for f in VISIBLE_LEN_FIXTURES], +) +def test_visible_len( + test_id: str, + text: str, + expected_len: int, +) -> None: + """_visible_len returns the visible character count, ignoring ANSI escapes.""" + assert _visible_len(text) == expected_len + + +# Spinner.add_output_line non-TTY write-through tests + + +class OutputLineFixture(t.NamedTuple): + """Test fixture for add_output_line TTY vs non-TTY behavior.""" + + test_id: str + isatty: bool + lines: list[str] + expected_deque: list[str] + expected_stream_contains: str + + +OUTPUT_LINE_FIXTURES: list[OutputLineFixture] = [ + OutputLineFixture( + "tty_accumulates_in_deque", + isatty=True, + lines=["line1\n", "line2\n"], + expected_deque=["line1", "line2"], + expected_stream_contains="", + ), + OutputLineFixture( + "non_tty_writes_to_stream", + isatty=False, + lines=["hello\n", "world\n"], + expected_deque=[], + expected_stream_contains="hello\nworld\n", + ), + OutputLineFixture( + "blank_lines_ignored", + isatty=True, + lines=["", "\n"], + expected_deque=[], + expected_stream_contains="", + ), +] + + +@pytest.mark.parametrize( + list(OutputLineFixture._fields), + OUTPUT_LINE_FIXTURES, + ids=[f.test_id for f in OUTPUT_LINE_FIXTURES], +) +def test_spinner_output_line_behavior( + test_id: str, + isatty: bool, + lines: list[str], + expected_deque: list[str], + expected_stream_contains: str, +) -> None: + """add_output_line accumulates in deque (TTY) or writes to stream (non-TTY).""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + for line in lines: + spinner.add_output_line(line) + + assert list(spinner._output_lines) == expected_deque + assert expected_stream_contains in stream.getvalue() + + +# Spinner.success tests + + +# Panel lines special values tests + + +class PanelLinesFixture(t.NamedTuple): + """Test fixture for Spinner panel_lines special values.""" + + test_id: str + output_lines: int + expected_maxlen: int | None # None = unbounded + expected_hidden: bool + add_count: int + expected_retained: int + + +PANEL_LINES_FIXTURES: list[PanelLinesFixture] = [ + PanelLinesFixture("zero_hides_panel", 0, 1, True, 10, 1), + PanelLinesFixture("negative_unlimited", -1, None, False, 100, 100), + PanelLinesFixture("positive_normal", 5, 5, False, 10, 5), + PanelLinesFixture("default_three", 3, 3, False, 5, 3), +] + + +@pytest.mark.parametrize( + list(PanelLinesFixture._fields), + PANEL_LINES_FIXTURES, + ids=[f.test_id for f in PANEL_LINES_FIXTURES], +) +def test_spinner_panel_lines_special_values( + test_id: str, + output_lines: int, + expected_maxlen: int | None, + expected_hidden: bool, + add_count: int, + expected_retained: int, +) -> None: + """Spinner panel_lines=0 hides, -1 is unlimited, positive caps normally.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=output_lines, + ) + for i in range(add_count): + spinner.add_output_line(f"line {i}") + + assert len(spinner._output_lines) == expected_retained + assert spinner._output_lines.maxlen == expected_maxlen + assert spinner._panel_hidden is expected_hidden + + +def test_spinner_unlimited_caps_rendered_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unlimited panel (-1) caps rendered lines to terminal_height - 2.""" + import os as _os + import shutil + + monkeypatch.setattr( + shutil, + "get_terminal_size", + lambda fallback=(80, 24): _os.terminal_size((80, 10)), + ) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=-1, + interval=0.01, + ) + for i in range(50): + spinner.add_output_line(f"line {i}") + + # All 50 lines should be retained in the unbounded deque + assert len(spinner._output_lines) == 50 + + # Start spinner briefly to render at least one frame + spinner.start() + time.sleep(0.05) + spinner.stop() + + output = stream.getvalue() + # Verify that not all 50 lines appear in any single frame + # The cap should limit to terminal_height - 2 = 8 lines + # Only the last 8 lines should appear in output + assert "line 49" in output + assert "line 0" not in output + + +class SuccessFixture(t.NamedTuple): + """Test fixture for Spinner.success() output behavior.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_contains: str + + +SUCCESS_FIXTURES: list[SuccessFixture] = [ + SuccessFixture("tty_with_color", True, ColorMode.ALWAYS, "done"), + SuccessFixture("tty_no_color", True, ColorMode.NEVER, "✓ done"), + SuccessFixture("non_tty", False, ColorMode.NEVER, "✓ done"), +] + + +@pytest.mark.parametrize( + list(SuccessFixture._fields), + SUCCESS_FIXTURES, + ids=[f.test_id for f in SUCCESS_FIXTURES], +) +def test_spinner_success_behavior( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_contains: str, +) -> None: + """success() always emits the checkmark message regardless of TTY/color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + spinner.success("done") + + output = stream.getvalue() + assert "✓" in output + assert expected_contains in output + + +# _truncate_visible tests + + +def test_truncate_visible_plain_text() -> None: + """Plain text is truncated to max_visible chars with default suffix.""" + assert _truncate_visible("hello world", 5) == "hello\x1b[0m..." + + +def test_truncate_visible_ansi_preserved() -> None: + """ANSI sequences are preserved whole; only visible chars count.""" + result = _truncate_visible("\033[32mgreen\033[0m", 3) + assert result == "\x1b[32mgre\x1b[0m..." + + +def test_truncate_visible_no_truncation() -> None: + """String shorter than max_visible is returned unchanged.""" + assert _truncate_visible("short", 10) == "short" + + +def test_truncate_visible_empty() -> None: + """Empty string returns empty string.""" + assert _truncate_visible("", 5) == "" + + +def test_truncate_visible_custom_suffix() -> None: + """Custom suffix is appended after truncation.""" + assert _truncate_visible("hello world", 5, suffix="~") == "hello\x1b[0m~" + + +def test_truncate_visible_no_suffix() -> None: + """Empty suffix produces only the reset sequence.""" + assert _truncate_visible("hello world", 5, suffix="") == "hello\x1b[0m" + + +# workspace_path token tests + + +def test_build_tree_workspace_path_in_context() -> None: + """workspace_path is available in _context() when set on construction.""" + tree = BuildTree(workspace_path="~/.tmuxp/foo.yaml") + tree.on_event({"event": "session_created", "name": "foo", "window_total": 1}) + ctx = tree._context() + assert ctx["workspace_path"] == "~/.tmuxp/foo.yaml" + + +def test_build_tree_workspace_path_empty_default() -> None: + """workspace_path defaults to empty string in _context().""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "s", "window_total": 1}) + assert tree._context()["workspace_path"] == "" + + +def test_spinner_workspace_path_passed_to_tree() -> None: + """Spinner passes workspace_path through to its BuildTree.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/proj.yaml", + ) + assert spinner._build_tree.workspace_path == "~/.tmuxp/proj.yaml" + + +def test_build_tree_workspace_path_in_template() -> None: + """workspace_path token resolves in format_template.""" + tree = BuildTree(workspace_path="~/.tmuxp/bar.yaml") + tree.on_event({"event": "session_created", "name": "bar", "window_total": 1}) + result = tree.format_template("{session} ({workspace_path})") + assert result == "bar (~/.tmuxp/bar.yaml)" + + +# {summary} token tests + + +def test_build_tree_summary_empty_state() -> None: + """Summary token is empty string before any windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["summary"] == "" + + +def test_build_tree_summary_after_windows_done() -> None: + """Summary token shows bracketed win/pane counts after windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 8, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w3", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[3 win, 8 panes]" + + +def test_build_tree_summary_windows_only_no_panes() -> None: + """Summary token shows only win count when pane_total is 0.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[2 win]" + + +def test_build_tree_summary_panes_only() -> None: + """Summary token shows only pane count when windows_done is 0 (edge case).""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 6, + } + ) + # Manually set session_panes_done without window_done to test edge case + tree.session_panes_done = 6 + assert tree._context()["summary"] == "[6 panes]" + + +# format_success() tests + + +def test_spinner_format_success_full_build() -> None: + """format_success renders SUCCESS_TEMPLATE with session, path, and summary.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/myapp.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "myapp", + "window_total": 3, + "session_pane_total": 8, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w1", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w2", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w3", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "myapp" in result + assert "~/.tmuxp/myapp.yaml" in result + assert "[3 win, 8 panes]" in result + + +def test_spinner_format_success_no_windows() -> None: + """format_success with no windows/panes done omits brackets.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/empty.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "empty", + "window_total": 0, + "session_pane_total": 0, + } + ) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "empty" in result + assert "~/.tmuxp/empty.yaml" in result + assert "[" not in result + + +# Spinner.success() with no args tests + + +def test_spinner_success_no_args_template_mode() -> None: + """success() with no args uses format_success when progress_format is set.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + workspace_path="~/.tmuxp/proj.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "proj", + "window_total": 1, + "session_pane_total": 2, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "main", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + spinner.success() + + output = stream.getvalue() + assert "✓" in output + assert "Loaded workspace:" in output + assert "proj" in output + assert "~/.tmuxp/proj.yaml" in output + assert "[1 win, 2 panes]" in output + + +def test_spinner_success_no_args_no_template() -> None: + """success() with no args and no progress_format falls back to _base_message.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading workspace: myapp", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + spinner.success() + + output = stream.getvalue() + assert "✓ Loading workspace: myapp" in output + + +def test_spinner_success_explicit_text_backward_compat() -> None: + """success('custom text') still works as before (backward compat).""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.success("custom done message") + + output = stream.getvalue() + assert "✓ custom done message" in output + + +# SUCCESS_TEMPLATE constant tests + + +def test_success_template_value() -> None: + """SUCCESS_TEMPLATE contains expected tokens.""" + assert "{session}" in SUCCESS_TEMPLATE + assert "{workspace_path}" in SUCCESS_TEMPLATE + assert "{summary}" in SUCCESS_TEMPLATE + assert "Loaded workspace:" in SUCCESS_TEMPLATE + + +def test_no_success_message_on_build_error( + server: libtmux.Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + """Success message is not emitted when _dispatch_build returns None.""" + import yaml + + from tmuxp.cli._colors import Colors + from tmuxp.cli.load import load_workspace + + monkeypatch.delenv("TMUX", raising=False) + + config = {"session_name": "test-fail", "windows": [{"window_name": "main"}]} + config_file = tmp_path / "fail.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.setattr( + "tmuxp.cli.load._dispatch_build", + lambda *args, **kwargs: None, + ) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + captured = capfd.readouterr() + assert "\u2713" not in captured.err + assert "Loaded workspace:" not in captured.err diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py new file mode 100644 index 0000000000..65a7093f65 --- /dev/null +++ b/tests/cli/test_prompt_colors.py @@ -0,0 +1,156 @@ +"""Tests for colored prompt utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_CYAN, ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors + + +def test_prompt_bool_choice_indicator_muted(colors_always: Colors) -> None: + """Verify [Y/n] uses muted color (blue).""" + # Test the muted color is applied to choice indicators + result = colors_always.muted("[Y/n]") + assert ANSI_BLUE in result # blue foreground + assert "[Y/n]" in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_bool_choice_indicator_variants(colors_always: Colors) -> None: + """Verify all choice indicator variants are colored.""" + for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: + result = colors_always.muted(indicator) + assert ANSI_BLUE in result + assert indicator in result + + +def test_prompt_default_value_uses_info(colors_always: Colors) -> None: + """Verify default path uses info color (cyan).""" + path = "/home/user/.tmuxp/session.yaml" + result = colors_always.info(f"[{path}]") + assert ANSI_CYAN in result # cyan foreground + assert path in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_choices_list_muted(colors_always: Colors) -> None: + """Verify (yaml, json) uses muted color (blue).""" + choices = "(yaml, json)" + result = colors_always.muted(choices) + assert ANSI_BLUE in result # blue foreground + assert choices in result + + +def test_prompts_respect_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR disables prompt colors.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.AUTO) + + assert colors.muted("[Y/n]") == "[Y/n]" + assert colors.info("[default]") == "[default]" + + +def test_prompt_combined_format(colors_always: Colors) -> None: + """Verify combined prompt format with choices and default.""" + name = "Convert to" + choices_str = colors_always.muted("(yaml, json)") + default_str = colors_always.info("[yaml]") + prompt = f"{name} - {choices_str} {default_str}" + + # Should contain both blue (muted) and cyan (info) ANSI codes + assert ANSI_BLUE in prompt # blue for choices + assert ANSI_CYAN in prompt # cyan for default + assert "Convert to" in prompt + assert "yaml, json" in prompt + + +def test_prompt_colors_disabled_returns_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text without ANSI codes.""" + assert colors_never.muted("[Y/n]") == "[Y/n]" + assert colors_never.info("[/path/to/file]") == "[/path/to/file]" + assert "\033[" not in colors_never.muted("test") + assert "\033[" not in colors_never.info("test") + + +def test_prompt_empty_input_no_default_reprompts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() re-prompts when user enters empty input with no default. + + This is a regression test for the bug where pressing Enter with no default + would cause an AssertionError instead of re-prompting. + """ + from tmuxp.cli.utils import prompt + + # Simulate: first input is empty (user presses Enter), second input is valid + inputs = iter(["", "valid_input"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter value") + assert result == "valid_input" + + +def test_prompt_empty_input_with_value_proc_no_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() with value_proc doesn't crash on empty input. + + This is a regression test for the AssertionError that occurred when + value_proc was provided but input was empty and no default was set. + """ + from tmuxp.cli.utils import prompt + + def validate_path(val: str) -> str: + """Validate that path is absolute.""" + if not val.startswith("/"): + msg = "Must be absolute path" + raise ValueError(msg) + return val + + # Simulate: first input is empty, second input is valid + inputs = iter(["", "/valid/path"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter path", value_proc=validate_path) + assert result == "/valid/path" + + +def test_prompt_default_uses_private_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Verify prompt() masks home directory in default value display. + + The displayed default should use PrivatePath to show ~ instead of + the full home directory path. + """ + import pathlib + + from tmuxp.cli.utils import prompt + + # Create a path under the user's home directory + home = pathlib.Path.home() + test_path = str(home / ".tmuxp" / "session.yaml") + + # Capture what prompt displays + displayed_prompt = None + + def capture_input(prompt_text: str) -> str: + nonlocal displayed_prompt + displayed_prompt = prompt_text + return "" # User presses Enter, accepting default + + monkeypatch.setattr("builtins.input", capture_input) + + result = prompt("Save to", default=test_path) + + # The result should be the original path (for actual saving) + assert result == test_path + + # The displayed prompt should use ~ instead of full home path + assert displayed_prompt is not None + assert "~/.tmuxp/session.yaml" in displayed_prompt + assert str(home) not in displayed_prompt diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py new file mode 100644 index 0000000000..9e67266b86 --- /dev/null +++ b/tests/cli/test_search.py @@ -0,0 +1,867 @@ +"""CLI tests for tmuxp search command.""" + +from __future__ import annotations + +import json +import pathlib +import re +import typing as t + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._output import OutputFormatter, OutputMode +from tmuxp.cli.search import ( + DEFAULT_FIELDS, + InvalidFieldError, + SearchPattern, + SearchToken, + WorkspaceFields, + WorkspaceSearchResult, + _get_field_values, + _output_search_results, + compile_search_patterns, + create_search_subparser, + evaluate_match, + extract_workspace_fields, + find_search_matches, + highlight_matches, + normalize_fields, + parse_query_terms, +) + + +class NormalizeFieldsFixture(t.NamedTuple): + """Test fixture for normalize_fields.""" + + test_id: str + fields: list[str] | None + expected: tuple[str, ...] + raises: type[Exception] | None + + +NORMALIZE_FIELDS_FIXTURES: list[NormalizeFieldsFixture] = [ + NormalizeFieldsFixture( + test_id="none_returns_defaults", + fields=None, + expected=DEFAULT_FIELDS, + raises=None, + ), + NormalizeFieldsFixture( + test_id="name_alias", + fields=["n"], + expected=("name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="session_aliases", + fields=["s", "session", "session_name"], + expected=("session_name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="path_alias", + fields=["p"], + expected=("path",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="window_alias", + fields=["w"], + expected=("window",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="multiple_fields", + fields=["name", "s", "window"], + expected=("name", "session_name", "window"), + raises=None, + ), + NormalizeFieldsFixture( + test_id="invalid_field", + fields=["invalid"], + expected=(), + raises=InvalidFieldError, + ), + NormalizeFieldsFixture( + test_id="case_insensitive", + fields=["NAME", "Session"], + expected=("name", "session_name"), + raises=None, + ), +] + + +@pytest.mark.parametrize( + NormalizeFieldsFixture._fields, + NORMALIZE_FIELDS_FIXTURES, + ids=[test.test_id for test in NORMALIZE_FIELDS_FIXTURES], +) +def test_normalize_fields( + test_id: str, + fields: list[str] | None, + expected: tuple[str, ...], + raises: type[Exception] | None, +) -> None: + """Test normalize_fields function.""" + if raises: + with pytest.raises(raises): + normalize_fields(fields) + else: + result = normalize_fields(fields) + assert result == expected + + +class ParseQueryTermsFixture(t.NamedTuple): + """Test fixture for parse_query_terms.""" + + test_id: str + terms: list[str] + expected_count: int + expected_first_fields: tuple[str, ...] | None + expected_first_pattern: str | None + + +PARSE_QUERY_TERMS_FIXTURES: list[ParseQueryTermsFixture] = [ + ParseQueryTermsFixture( + test_id="simple_term", + terms=["dev"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="name_prefix", + terms=["name:dev"], + expected_count=1, + expected_first_fields=("name",), + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="session_prefix", + terms=["s:production"], + expected_count=1, + expected_first_fields=("session_name",), + expected_first_pattern="production", + ), + ParseQueryTermsFixture( + test_id="multiple_terms", + terms=["dev", "production"], + expected_count=2, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="url_not_field", + terms=["http://example.com"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="http://example.com", + ), + ParseQueryTermsFixture( + test_id="empty_pattern_skipped", + terms=["name:"], + expected_count=0, + expected_first_fields=None, + expected_first_pattern=None, + ), + ParseQueryTermsFixture( + test_id="path_with_colons", + terms=["path:/home/user/project"], + expected_count=1, + expected_first_fields=("path",), + expected_first_pattern="/home/user/project", + ), +] + + +@pytest.mark.parametrize( + ParseQueryTermsFixture._fields, + PARSE_QUERY_TERMS_FIXTURES, + ids=[test.test_id for test in PARSE_QUERY_TERMS_FIXTURES], +) +def test_parse_query_terms( + test_id: str, + terms: list[str], + expected_count: int, + expected_first_fields: tuple[str, ...] | None, + expected_first_pattern: str | None, +) -> None: + """Test parse_query_terms function.""" + result = parse_query_terms(terms) + + assert len(result) == expected_count + + if expected_count > 0: + assert result[0].fields == expected_first_fields + assert result[0].pattern == expected_first_pattern + + +class CompileSearchPatternsFixture(t.NamedTuple): + """Test fixture for compile_search_patterns.""" + + test_id: str + pattern: str + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + test_string: str + should_match: bool + + +COMPILE_SEARCH_PATTERNS_FIXTURES: list[CompileSearchPatternsFixture] = [ + CompileSearchPatternsFixture( + test_id="basic_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="case_sensitive_no_match", + pattern="DEV", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="ignore_case_match", + pattern="DEV", + ignore_case=True, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_lowercase", + pattern="dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="DEVELOPMENT", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_uppercase_no_match", + pattern="Dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_literal", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="dev.*project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_no_regex", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="my dev project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_no_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="regex_pattern", + pattern="dev.*proj", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="dev-project", + should_match=True, + ), +] + + +@pytest.mark.parametrize( + CompileSearchPatternsFixture._fields, + COMPILE_SEARCH_PATTERNS_FIXTURES, + ids=[test.test_id for test in COMPILE_SEARCH_PATTERNS_FIXTURES], +) +def test_compile_search_patterns( + test_id: str, + pattern: str, + ignore_case: bool, + smart_case: bool, + fixed_strings: bool, + word_regexp: bool, + test_string: str, + should_match: bool, +) -> None: + """Test compile_search_patterns function.""" + tokens = [SearchToken(fields=("name",), pattern=pattern)] + + patterns = compile_search_patterns( + tokens, + ignore_case=ignore_case, + smart_case=smart_case, + fixed_strings=fixed_strings, + word_regexp=word_regexp, + ) + + assert len(patterns) == 1 + match = patterns[0].regex.search(test_string) + assert bool(match) == should_match + + +def test_compile_search_patterns_invalid_regex_raises() -> None: + """Invalid regex pattern raises re.error.""" + tokens = [SearchToken(fields=("name",), pattern="[invalid(")] + with pytest.raises(re.error): + compile_search_patterns(tokens) + + +def test_extract_workspace_fields_basic(tmp_path: pathlib.Path) -> None: + """Extract fields from basic workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: my-session\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "my-session" + assert "editor" in fields["windows"] + assert "shell" in fields["windows"] + assert "vim" in fields["panes"] + + +def test_extract_workspace_fields_pane_shell_command_dict( + tmp_path: pathlib.Path, +) -> None: + """Extract pane commands from dict format.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: test\n" + "windows:\n" + " - window_name: main\n" + " panes:\n" + " - shell_command: git status\n" + " - shell_command:\n" + " - npm install\n" + " - npm start\n" + ) + + fields = extract_workspace_fields(workspace) + + assert "git status" in fields["panes"] + assert "npm install" in fields["panes"] + assert "npm start" in fields["panes"] + + +def test_extract_workspace_fields_missing_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows:\n - window_name: main\n") + + fields = extract_workspace_fields(workspace) + + assert fields["session_name"] == "" + assert fields["name"] == "test" + + +def test_extract_workspace_fields_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "" + assert fields["windows"] == [] + + +def test_extract_workspace_fields_path_uses_privacy( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path should use PrivatePath for home contraction.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\n") + + fields = extract_workspace_fields(workspace) + + assert fields["path"] == "~/test.yaml" + + +@pytest.fixture() +def sample_fields() -> WorkspaceFields: + """Sample workspace fields for testing.""" + return WorkspaceFields( + name="dev-project", + path="~/.tmuxp/dev-project.yaml", + session_name="development", + windows=["editor", "shell", "logs"], + panes=["vim", "git status", "tail -f"], + ) + + +def test_evaluate_match_single_pattern(sample_fields: WorkspaceFields) -> None: + """Single pattern should match.""" + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "name" in matches + + +def test_evaluate_match_single_pattern_no_match(sample_fields: WorkspaceFields) -> None: + """Single pattern should not match.""" + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is False + assert matches == {} + + +def test_evaluate_match_and_logic_all_match(sample_fields: WorkspaceFields) -> None: + """AND logic - all patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is True + + +def test_evaluate_match_and_logic_partial_no_match( + sample_fields: WorkspaceFields, +) -> None: + """AND logic - only some patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is False + + +def test_evaluate_match_or_logic_any_match(sample_fields: WorkspaceFields) -> None: + """OR logic - any pattern matches.""" + p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) + + assert matched is True + + +def test_evaluate_match_window_field(sample_fields: WorkspaceFields) -> None: + """Search in window field.""" + pattern = SearchPattern( + fields=("window",), + raw="editor", + regex=re.compile("editor"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "window" in matches + + +def test_evaluate_match_pane_field(sample_fields: WorkspaceFields) -> None: + """Search in pane field.""" + pattern = SearchPattern( + fields=("pane",), + raw="vim", + regex=re.compile("vim"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "pane" in matches + + +def test_evaluate_match_multiple_fields(sample_fields: WorkspaceFields) -> None: + """Pattern searches multiple fields.""" + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + # Should find matches in both name and session_name + assert "name" in matches or "session_name" in matches + + +def test_find_search_matches_basic(tmp_path: pathlib.Path) -> None: + """Basic search finds matching workspace.""" + workspace = tmp_path / "dev.yaml" + workspace.write_text("session_name: development\n") + + pattern = SearchPattern( + fields=("session_name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["source"] == "global" + + +def test_find_search_matches_no_match(tmp_path: pathlib.Path) -> None: + """Search returns empty when no match.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 0 + + +def test_find_search_matches_invert(tmp_path: pathlib.Path) -> None: + """Invert match returns non-matching workspaces.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern], invert_match=True) + + assert len(results) == 1 + + +def test_find_search_matches_multiple_workspaces(tmp_path: pathlib.Path) -> None: + """Search across multiple workspaces.""" + ws1 = tmp_path / "dev.yaml" + ws1.write_text("session_name: development\n") + + ws2 = tmp_path / "prod.yaml" + ws2.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["fields"]["name"] == "dev" + + +def test_highlight_matches_no_colors() -> None: + """Colors disabled returns original text.""" + colors = Colors(ColorMode.NEVER) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_with_colors() -> None: + """Colors enabled adds ANSI codes.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert "\033[" in result # Contains ANSI escape + assert "dev" in result + + +def test_highlight_matches_no_match() -> None: + """No match returns original text.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_multiple() -> None: + """Multiple matches in same string.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="e", + regex=re.compile("e"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + # Should contain multiple highlights + assert result.count("\033[") > 1 + + +def test_highlight_matches_empty_patterns() -> None: + """Empty patterns returns original text.""" + colors = Colors(ColorMode.ALWAYS) + + result = highlight_matches("development", [], colors=colors) + + assert result == "development" + + +@pytest.fixture() +def sample_fields_for_get_field_values() -> WorkspaceFields: + """Sample workspace fields.""" + return WorkspaceFields( + name="test", + path="~/.tmuxp/test.yaml", + session_name="test-session", + windows=["editor", "shell"], + panes=["vim", "bash"], + ) + + +def test_get_field_values_scalar( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Scalar field returns list with one item.""" + result = _get_field_values(sample_fields_for_get_field_values, "name") + assert result == ["test"] + + +def test_get_field_values_list( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """List field returns the list.""" + result = _get_field_values(sample_fields_for_get_field_values, "windows") + assert result == ["editor", "shell"] + + +def test_get_field_values_window_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Window alias maps to windows.""" + result = _get_field_values(sample_fields_for_get_field_values, "window") + assert result == ["editor", "shell"] + + +def test_get_field_values_pane_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Pane alias maps to panes.""" + result = _get_field_values(sample_fields_for_get_field_values, "pane") + assert result == ["vim", "bash"] + + +def test_get_field_values_empty() -> None: + """Empty value returns empty list.""" + fields = WorkspaceFields( + name="", + path="", + session_name="", + windows=[], + panes=[], + ) + result = _get_field_values(fields, "name") + assert result == [] + + +def test_search_subparser_creation() -> None: + """Subparser can be created successfully.""" + import argparse + + parser = argparse.ArgumentParser() + result = create_search_subparser(parser) + + assert result is parser + + +def test_search_subparser_options() -> None: + """Parser has expected options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + # Parse with various options + args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) + + assert args.ignore_case is True + assert args.smart_case is True + assert args.fixed_strings is True + assert args.word_regexp is True + assert args.invert_match is True + assert args.match_any is True + assert args.query_terms == ["pattern"] + + +def test_search_subparser_output_format_options() -> None: + """Parser supports output format options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args_json = parser.parse_args(["--json", "test"]) + assert args_json.output_json is True + + args_ndjson = parser.parse_args(["--ndjson", "test"]) + assert args_ndjson.output_ndjson is True + + +def test_search_subparser_field_option() -> None: + """Parser supports field option.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args = parser.parse_args(["-f", "name", "-f", "session", "test"]) + + assert args.field == ["name", "session"] + + +def test_output_search_results_no_results(capsys: pytest.CaptureFixture[str]) -> None: + """No results outputs warning message.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.HUMAN) + + _output_search_results([], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "No matching" in captured.out + + +def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: + """JSON output mode produces valid JSON.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.JSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=["editor"], + panes=["vim"], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + +def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON output mode produces one JSON per line.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.NDJSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=[], + panes=[], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + assert len(json_lines) >= 1 + data = json.loads(json_lines[0]) + assert data["name"] == "dev" diff --git a/tests/cli/test_shell.py b/tests/cli/test_shell.py new file mode 100644 index 0000000000..da97afb237 --- /dev/null +++ b/tests/cli/test_shell.py @@ -0,0 +1,473 @@ +"""CLI tests for tmuxp shell.""" + +from __future__ import annotations + +import contextlib +import io +import subprocess +import typing as t + +import pytest + +from tmuxp import cli, exc + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + from libtmux.session import Session + + +class CLIShellFixture(t.NamedTuple): + """Test fixture for tmuxp shell tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[str, str] + expected_output: str + + +TEST_SHELL_FIXTURES: list[CLIShellFixture] = [ + # Regular shell command + CLIShellFixture( + test_id="print-socket-name", + cli_cmd=["shell"], + cli_args=["-L{SOCKET_NAME}", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + expected_output="{SERVER_SOCKET_NAME}", + ), + CLIShellFixture( + test_id="print-session-name", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "-c", + "print(session.name)", + ], + inputs=[], + env={}, + expected_output="{SESSION_NAME}", + ), + CLIShellFixture( + test_id="print-has-session", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(server.has_session(session.name))", + ], + inputs=[], + env={}, + expected_output="True", + ), + CLIShellFixture( + test_id="print-window-name", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(window.name)", + ], + inputs=[], + env={}, + expected_output="{WINDOW_NAME}", + ), + CLIShellFixture( + test_id="print-pane-id", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={}, + expected_output="{PANE_ID}", + ), + CLIShellFixture( + test_id="print-pane-id-obeys-tmux-pane-env-var", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={"TMUX_PANE": "{PANE_ID}"}, + expected_output="{PANE_ID}", + ), + # Shell with --pdb + CLIShellFixture( + test_id="print-socket-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=["-L{SOCKET_NAME}", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + expected_output="{SERVER_SOCKET_NAME}", + ), + CLIShellFixture( + test_id="print-session-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "-c", + "print(session.name)", + ], + inputs=[], + env={}, + expected_output="{SESSION_NAME}", + ), + CLIShellFixture( + test_id="print-has-session-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(server.has_session(session.name))", + ], + inputs=[], + env={}, + expected_output="True", + ), + CLIShellFixture( + test_id="print-window-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(window.name)", + ], + inputs=[], + env={}, + expected_output="{WINDOW_NAME}", + ), + CLIShellFixture( + test_id="print-pane-id-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={}, + expected_output="{PANE_ID}", + ), + CLIShellFixture( + test_id="print-pane-id-obeys-tmux-pane-env-var-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={"TMUX_PANE": "{PANE_ID}"}, + expected_output="{PANE_ID}", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellFixture._fields), + TEST_SHELL_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_FIXTURES], +) +def test_shell( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[str, str], + expected_output: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI tests for tmuxp shell.""" + monkeypatch.setenv("HOME", str(tmp_path)) + # Clear outer tmux environment to prevent pane ID conflicts + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + window_name = "my_window" + window = session.new_window(window_name=window_name) + window.split() + + assert window.active_pane is not None + + template_ctx = { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": window_name, + "PANE_ID": window.active_pane.id, + "SERVER_SOCKET_NAME": server.socket_name, + } + + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + + cli.cli(cli_args) + result = capsys.readouterr() + assert expected_output.format(**template_ctx) in result.out + + +class CLIShellTargetMissingFixture(t.NamedTuple): + """Test fixture for tmuxp shell target missing tests.""" + + test_id: str + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[t.Any, t.Any] + template_ctx: dict[str, str] + exception: type[exc.TmuxpException | subprocess.CalledProcessError] + message: str + + +TEST_SHELL_TARGET_MISSING_FIXTURES: list[CLIShellTargetMissingFixture] = [ + # Regular shell command + CLIShellTargetMissingFixture( + test_id="nonexistent_socket", + cli_cmd=["shell"], + cli_args=["-LDoesNotExist", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + template_ctx={}, + exception=subprocess.CalledProcessError, + message=r".*DoesNotExist.*", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_session", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "nonexistent_session", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"session_name": "nonexistent_session"}, + exception=exc.TmuxpException, + message="Session not found: nonexistent_session", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_window", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "nonexistent_window", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"window_name": "nonexistent_window"}, + exception=exc.TmuxpException, + message="Window not found: {WINDOW_NAME}", + ), + # Shell with --pdb + CLIShellTargetMissingFixture( + test_id="nonexistent_socket_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=["-LDoesNotExist", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + template_ctx={}, + exception=subprocess.CalledProcessError, + message=r".*DoesNotExist.*", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_session_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "nonexistent_session", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"session_name": "nonexistent_session"}, + exception=exc.TmuxpException, + message="Session not found: nonexistent_session", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_window_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "nonexistent_window", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"window_name": "nonexistent_window"}, + exception=exc.TmuxpException, + message="Window not found: {WINDOW_NAME}", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellTargetMissingFixture._fields), + TEST_SHELL_TARGET_MISSING_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_TARGET_MISSING_FIXTURES], +) +def test_shell_target_missing( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[t.Any, t.Any], + template_ctx: dict[str, str], + exception: type[exc.TmuxpException | subprocess.CalledProcessError], + message: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI tests for tmuxp shell when target is not specified.""" + monkeypatch.setenv("HOME", str(tmp_path)) + window_name = "my_window" + window = session.new_window(window_name=window_name) + window.split() + + assert server.socket_name is not None + assert session.name is not None + + template_ctx.update( + { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": template_ctx.get("window_name", window_name), + }, + ) + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + + if exception is not None: + with pytest.raises(exception, match=message.format(**template_ctx)): + cli.cli(cli_args) + else: + cli.cli(cli_args) + result = capsys.readouterr() + assert message.format(**template_ctx) in result.out + + +class CLIShellInteractiveFixture(t.NamedTuple): + """Test fixture for tmuxp shell interactive tests.""" + + test_id: str + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[str, str] + message: str + + +TEST_SHELL_INTERACTIVE_FIXTURES: list[CLIShellInteractiveFixture] = [ + CLIShellInteractiveFixture( + test_id="basic_interactive", + cli_cmd=["shell", "--code"], + cli_args=[ + "-L{SOCKET_NAME}", + ], + inputs=[], + env={}, + message="(InteractiveConsole)", + ), + CLIShellInteractiveFixture( + test_id="interactive_with_pane_id", + cli_cmd=["shell", "--code"], + cli_args=[ + "-L{SOCKET_NAME}", + ], + inputs=[], + env={"PANE_ID": "{PANE_ID}"}, + message="(InteractiveConsole)", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellInteractiveFixture._fields), + TEST_SHELL_INTERACTIVE_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_INTERACTIVE_FIXTURES], +) +def test_shell_interactive( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[str, str], + message: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI tests for tmuxp shell when shell is specified.""" + monkeypatch.setenv("HOME", str(tmp_path)) + window_name = "my_window" + window = session.new_window(window_name=window_name) + window.split() + + assert window.active_pane is not None + + template_ctx = { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": window_name, + "PANE_ID": window.active_pane.id, + "SERVER_SOCKET_NAME": server.socket_name, + } + + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.stdin", io.StringIO("exit()\r")) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + assert message.format(**template_ctx) in result.err diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py new file mode 100644 index 0000000000..de92e748ae --- /dev/null +++ b/tests/cli/test_shell_colors.py @@ -0,0 +1,95 @@ +"""Tests for CLI colors in shell command.""" + +from __future__ import annotations + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp.cli._colors import Colors + +# Shell command color output tests + + +def test_shell_launch_message_format(colors_always: Colors) -> None: + """Verify launch message format with shell type and session.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight(shell_name, bold=False) + + colors_always.muted(" shell for session ") + + colors_always.info(session_name) + + colors_always.muted("...") + ) + # Should contain blue, magenta, and cyan ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for highlight + assert ANSI_CYAN in output # cyan for session name + assert shell_name in output + assert session_name in output + + +def test_shell_pdb_launch_message(colors_always: Colors) -> None: + """Verify pdb launch message format.""" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight("pdb", bold=False) + + colors_always.muted(" shell...") + ) + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for pdb + assert "pdb" in output + + +def test_shell_highlight_not_bold(colors_always: Colors) -> None: + """Verify shell name uses highlight without bold for subtlety.""" + result = colors_always.highlight("best", bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle emphasis + assert "best" in result + + +def test_shell_session_name_uses_info(colors_always: Colors) -> None: + """Verify session name uses info color (cyan).""" + session_name = "dev-session" + result = colors_always.info(session_name) + assert ANSI_CYAN in result # cyan foreground + assert session_name in result + + +def test_shell_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Launching ") + assert ANSI_BLUE in result # blue foreground + assert "Launching" in result + + +def test_shell_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_never.muted("Launching ") + + colors_never.highlight(shell_name, bold=False) + + colors_never.muted(" shell for session ") + + colors_never.info(session_name) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Launching {shell_name} shell for session {session_name}..." + + +def test_shell_various_shell_names(colors_always: Colors) -> None: + """Verify all shell types can be highlighted.""" + shell_types = [ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + for shell_name in shell_types: + result = colors_always.highlight(shell_name, bold=False) + assert ANSI_MAGENTA in result + assert shell_name in result diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ce364c6b1a..0000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- coding: utf-8 -*- - -import logging - -import pytest - -from libtmux import exc -from libtmux.server import Server -from libtmux.test import TEST_SESSION_PREFIX, get_test_session_name, namer - -logger = logging.getLogger(__name__) - - -@pytest.fixture(scope='function') -def server(request): - t = Server() - t.socket_name = 'tmuxp_test%s' % next(namer) - - def fin(): - t.kill_server() - request.addfinalizer(fin) - - return t - - -@pytest.fixture(scope='function') -def session(server): - session_name = 'tmuxp' - - if not server.has_session(session_name): - server.cmd('new-session', '-d', '-s', session_name) - - # find current sessions prefixed with tmuxp - old_test_sessions = [ - s.get('session_name') for s in server._sessions - if s.get('session_name').startswith(TEST_SESSION_PREFIX) - ] - - TEST_SESSION_NAME = get_test_session_name(server=server) - - try: - session = server.new_session( - session_name=TEST_SESSION_NAME, - ) - except exc.LibTmuxException as e: - raise e - - """ - Make sure that tmuxp can :ref:`test_builder_visually` and switches to - the newly created session for that testcase. - """ - try: - server.switch_client(session.get('session_id')) - pass - except exc.LibTmuxException as e: - # server.attach_session(session.get('session_id')) - pass - - for old_test_session in old_test_sessions: - logger.debug( - 'Old test test session %s found. Killing it.' % - old_test_session - ) - server.kill_session(old_test_session) - assert TEST_SESSION_NAME == session.get('session_name') - assert TEST_SESSION_NAME != 'tmuxp' - - return session - - -@pytest.fixture() -def tmpdir(tmpdir_factory): - fn = tmpdir_factory.mktemp('tmuxp') - return fn diff --git a/tests/constants.py b/tests/constants.py new file mode 100644 index 0000000000..ee67d4c770 --- /dev/null +++ b/tests/constants.py @@ -0,0 +1,9 @@ +"""Constant variables for tmuxp tests.""" + +from __future__ import annotations + +import pathlib + +TESTS_PATH = pathlib.Path(__file__).parent +EXAMPLE_PATH = TESTS_PATH.parent / "examples" +FIXTURE_PATH = TESTS_PATH / "fixtures" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 3e227e3c07..2f0841acd8 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1 +1,5 @@ -from . import _util # noqa +"""Fixture test data for tmuxp.""" + +from __future__ import annotations + +from . import utils diff --git a/tests/fixtures/_util.py b/tests/fixtures/_util.py deleted file mode 100644 index 67d2d05e22..0000000000 --- a/tests/fixtures/_util.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - - -def curjoin(_file): # return filepath relative to __file__ (this file) - return os.path.join(os.path.dirname(__file__), _file) - - -def loadfixture(_file): # return fixture data, relative to __file__ - return open(curjoin(_file)).read() diff --git a/tests/fixtures/config/__init__.py b/tests/fixtures/config/__init__.py deleted file mode 100644 index 2c908611bf..0000000000 --- a/tests/fixtures/config/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import (expand1, expand2, expand_blank, sampleconfig, # noqa - shell_command_before, shell_command_before_session, trickle) diff --git a/tests/fixtures/config/expand1.py b/tests/fixtures/config/expand1.py deleted file mode 100644 index 4c75c410bb..0000000000 --- a/tests/fixtures/config/expand1.py +++ /dev/null @@ -1,135 +0,0 @@ -import os - -before_config = { - 'session_name': 'sampleconfig', - 'start_directory': '~', - 'windows': [ - { - 'window_name': 'editor', - 'panes': [ - { - 'shell_command': [ - 'vim', - 'top' - ] - }, - { - 'shell_command': ['vim'], - }, - { - 'shell_command': 'cowsay "hey"' - } - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - } - ] - }, - { - 'start_directory': '/var/log', - 'options': {'automatic-rename': True, }, - 'panes': [ - { - 'shell_command': 'htop' - }, - 'vim', - ] - }, - { - 'start_directory': './', - 'panes': [ - 'pwd' - ] - }, - { - 'start_directory': './asdf/', - 'panes': [ - 'pwd' - ] - }, - { - 'start_directory': '../', - 'panes': [ - 'pwd' - ] - }, - { - 'panes': [ - 'top' - ] - } - ] -} - -after_config = { - 'session_name': 'sampleconfig', - 'start_directory': os.path.expanduser('~'), - 'windows': [ - { - 'window_name': 'editor', - 'panes': [ - { - 'shell_command': ['vim', 'top'], - }, - { - 'shell_command': ['vim'], - }, - { - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - } - ] - }, - { - 'start_directory': '/var/log', - 'options': {'automatic-rename': True}, - 'panes': [ - {'shell_command': ['htop']}, - {'shell_command': ['vim']} - ] - }, - { - 'start_directory': os.path.normpath( - os.path.join(os.path.expanduser('~'), './') - ), - 'panes': [ - {'shell_command': ['pwd']} - ] - }, - { - 'start_directory': os.path.normpath( - os.path.join(os.path.expanduser('~'), './asdf') - ), - 'panes': [ - {'shell_command': ['pwd']} - ] - }, - { - 'start_directory': os.path.normpath( - os.path.join(os.path.expanduser('~'), '../') - ), - 'panes': [ - {'shell_command': ['pwd']} - ] - }, - - { - 'panes': [ - {'shell_command': ['top']} - ] - } - ] -} diff --git a/tests/fixtures/config/expand2.py b/tests/fixtures/config/expand2.py deleted file mode 100644 index 502ed04248..0000000000 --- a/tests/fixtures/config/expand2.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -from .._util import loadfixture - -unexpanded_yaml = loadfixture('config/expand2-unexpanded.yaml') -expanded_yaml = loadfixture('config/expand2-expanded.yaml').format( - HOME=os.path.expanduser('~') -) diff --git a/tests/fixtures/config/expand_blank.py b/tests/fixtures/config/expand_blank.py deleted file mode 100644 index b1a62ee5cb..0000000000 --- a/tests/fixtures/config/expand_blank.py +++ /dev/null @@ -1,66 +0,0 @@ -expected = { - 'session_name': 'Blank pane test', - 'windows': [ - { - 'window_name': 'Blank pane test', - 'panes': [ - { - 'shell_command': [], - }, - { - 'shell_command': [], - }, - { - 'shell_command': [], - } - ] - }, - { - 'window_name': 'More blank panes', - 'panes': [ - { - 'shell_command': [], - }, - { - 'shell_command': [], - }, - { - 'shell_command': [], - } - ] - }, - { - 'window_name': 'Empty string (return)', - 'panes': [ - { - 'shell_command': [ - '' - ], - }, - { - 'shell_command': [ - '' - ], - }, - { - 'shell_command': [ - '' - ], - } - ] - }, - { - 'window_name': 'Blank with options', - 'panes': [ - { - 'shell_command': [], - 'focus': True, - }, - { - 'shell_command': [], - 'start_directory': '/tmp', - } - ] - } - ] -} diff --git a/tests/fixtures/config/sampleconfig.py b/tests/fixtures/config/sampleconfig.py deleted file mode 100644 index 13ac219466..0000000000 --- a/tests/fixtures/config/sampleconfig.py +++ /dev/null @@ -1,33 +0,0 @@ -sampleconfigdict = { - 'session_name': 'sampleconfig', - 'start_directory': '~', - 'windows': [ - { - 'window_name': 'editor', - 'panes': [ - { - 'start_directory': '~', - 'shell_command': ['vim'], - }, { - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [{ - 'shell_command': ['tail -F /var/log/syslog'], - 'start_directory':'/var/log' - }] - }, - { - 'options': { - 'automatic_rename': True, - }, - 'panes': [ - {'shell_command': ['htop']} - ] - } - ] -} diff --git a/tests/fixtures/config/shell_command_before.py b/tests/fixtures/config/shell_command_before.py deleted file mode 100644 index caf9504b3f..0000000000 --- a/tests/fixtures/config/shell_command_before.py +++ /dev/null @@ -1,174 +0,0 @@ -import os - -config_unexpanded = { # shell_command_before is string in some areas - 'session_name': 'sampleconfig', - 'start_directory': '/', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': '~', - 'shell_command_before': 'source .venv/bin/activate', - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'shell_command_before': 'rbenv local 2.0.0-p0', - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - }, - { - } - ] - }, - { - 'window_name': 'shufu', - 'panes': [ - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': ['htop'], - } - ] - }, - { - 'options': {'automatic-rename': True, }, - 'panes': [ - {'shell_command': ['htop']} - ] - }, - { - 'panes': ['top'] - } - ] -} - -config_expanded = { # shell_command_before is string in some areas - 'session_name': 'sampleconfig', - 'start_directory': '/', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': os.path.expanduser('~'), - 'shell_command_before': ['source .venv/bin/activate'], - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - }, - { - 'shell_command': [] - } - ] - }, - { - 'window_name': 'shufu', - 'panes': [ - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': ['htop'], - } - ] - }, - { - 'options': {'automatic-rename': True, }, - 'panes': [ - {'shell_command': ['htop']} - ] - }, - { - 'panes': [{ - 'shell_command': ['top'] - }] - }, - ] -} - -config_after = { # shell_command_before is string in some areas - 'session_name': 'sampleconfig', - 'start_directory': '/', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': os.path.expanduser('~'), - 'shell_command_before': ['source .venv/bin/activate'], - 'panes': [ - { - 'shell_command': ['source .venv/bin/activate', 'vim'], - }, - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': [ - 'source .venv/bin/activate', - 'rbenv local 2.0.0-p0', 'cowsay "hey"' - ] - }, - ], - 'layout': 'main-verticle' - }, - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'start_directory': '/', - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': [ - 'rbenv local 2.0.0-p0', - 'tail -F /var/log/syslog' - ], - }, - { - 'shell_command': ['rbenv local 2.0.0-p0'] - } - ] - }, - { - 'start_directory': '/', - 'window_name': 'shufu', - 'panes': [ - { - 'shell_command_before': ['rbenv local 2.0.0-p0'], - 'shell_command': ['rbenv local 2.0.0-p0', 'htop'], - } - ] - }, - { - 'start_directory': '/', - 'options': {'automatic-rename': True, }, - 'panes': [ - { - 'shell_command': ['htop'] - } - ] - }, - { - 'start_directory': '/', - 'panes': [ - { - 'shell_command': ['top'] - } - ] - } - ] -} diff --git a/tests/fixtures/config/shell_command_before_session-expected.yaml b/tests/fixtures/config/shell_command_before_session-expected.yaml deleted file mode 100644 index 4e1e3eb49a..0000000000 --- a/tests/fixtures/config/shell_command_before_session-expected.yaml +++ /dev/null @@ -1,23 +0,0 @@ -shell_command_before: - - 'echo "hi"' -session_name: 'test' -windows: -- window_name: editor - panes: - - shell_command: - - 'echo "hi"' - - vim - - :Ex - - shell_command: - - 'echo "hi"' - - shell_command: - - 'echo "hi"' - - cd /usr -- window_name: logging - panes: - - shell_command: - - 'echo "hi"' - - shell_command: - - 'echo "hi"' - - top - - emacs diff --git a/tests/fixtures/config/shell_command_before_session.py b/tests/fixtures/config/shell_command_before_session.py deleted file mode 100644 index ef1e89e60c..0000000000 --- a/tests/fixtures/config/shell_command_before_session.py +++ /dev/null @@ -1,4 +0,0 @@ -from .._util import loadfixture - -before = loadfixture("config/shell_command_before_session.yaml") -expected = loadfixture("config/shell_command_before_session-expected.yaml") diff --git a/tests/fixtures/config/trickle.py b/tests/fixtures/config/trickle.py deleted file mode 100644 index da67df5d1b..0000000000 --- a/tests/fixtures/config/trickle.py +++ /dev/null @@ -1,65 +0,0 @@ -before = { # shell_command_before is string in some areas - 'session_name': 'sampleconfig', - 'start_directory': '/var', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': 'log', - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'start_directory': '~', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - }, - { - 'shell_command': [] - } - ] - }, - ] -} - -expected = { # shell_command_before is string in some areas - 'session_name': 'sampleconfig', - 'start_directory': '/var', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': '/var/log', - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command': [ - 'cowsay "hey"' - ] - }, - ], - 'layout': 'main-verticle' - }, - { - 'start_directory': '~', - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - }, - { - 'shell_command': [] - } - ] - }, - ] -} diff --git a/tests/fixtures/config_teamocil/__init__.py b/tests/fixtures/config_teamocil/__init__.py deleted file mode 100644 index 822441fa3e..0000000000 --- a/tests/fixtures/config_teamocil/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test1, test2, test3, test4, layouts # noqa diff --git a/tests/fixtures/config_teamocil/layouts.py b/tests/fixtures/config_teamocil/layouts.py deleted file mode 100644 index a40bcd3d7a..0000000000 --- a/tests/fixtures/config_teamocil/layouts.py +++ /dev/null @@ -1,275 +0,0 @@ -from .._util import loadfixture - -teamocil_yaml = loadfixture('config_teamocil/layouts.yaml') - -teamocil_dict = { - 'two-windows': { - 'windows': [ - { - 'name': 'foo', - 'clear': True, - 'root': '/foo', - 'layout': 'tiled', - 'panes': [ - { - 'cmd': "echo 'foo'" - }, - { - 'cmd': "echo 'foo again'" - } - ] - }, - { - 'name': 'bar', - 'root': '/bar', - 'splits': [ - { - 'cmd': [ - "echo 'bar'", - "echo 'bar in an array'" - ], - 'target': 'bottom-right' - }, - { - 'cmd': "echo 'bar again'", - 'focus': True, - 'width': 50 - } - ] - - } - ] - }, - - 'two-windows-with-filters': { - 'windows': [ - { - 'name': 'foo', - 'root': '/foo', - 'filters': - { - 'before': [ - 'echo first before filter', - 'echo second before filter' - ], - 'after': [ - 'echo first after filter', - 'echo second after filter', - ] - }, - 'panes': [ - { - 'cmd': "echo 'foo'" - }, - { - 'cmd': "echo 'foo again'", - 'width': 50 - } - ] - } - ] - }, - - 'two-windows-with-custom-command-options': { - 'windows': [ - { - 'name': 'foo', - 'cmd_separator': '\n', - 'with_env_var': False, - 'clear': True, - 'root': '/foo', - 'layout': 'tiled', - 'panes': [ - { - 'cmd': "echo 'foo'" - }, - { - 'cmd': "echo 'foo again'" - } - ] - }, { - 'name': 'bar', - 'cmd_separator': ' && ', - 'with_env_var': True, - 'root': '/bar', - 'splits': [ - { - 'cmd': [ - "echo 'bar'", - "echo 'bar in an array'" - ], - 'target': 'bottom-right' - }, - { - 'cmd': "echo 'bar again'", - 'focus': True, - 'width': 50 - } - ] - }] - }, - - 'three-windows-within-a-session': { - 'session': { - 'name': 'my awesome session', - 'windows': [ - { - 'name': 'first window', - 'panes': [ - { - 'cmd': "echo 'foo'" - } - ] - }, { - 'name': 'second window', - 'panes': [ - { - 'cmd': "echo 'foo'"} - ] - }, { - 'name': 'third window', - 'panes': [ - { - 'cmd': "echo 'foo'" - } - ] - } - ] - } - } -} - - -two_windows = \ - { - 'session_name': None, - 'windows': [ - { - 'window_name': 'foo', - 'start_directory': '/foo', - 'clear': True, - 'layout': 'tiled', - 'panes': [ - { - 'shell_command': "echo 'foo'" - }, - { - 'shell_command': "echo 'foo again'" - } - ] - }, - { - 'window_name': 'bar', - 'start_directory': '/bar', - 'panes': [ - { - 'shell_command': [ - "echo 'bar'", - "echo 'bar in an array'" - ], - 'target': 'bottom-right' - }, - { - 'shell_command': "echo 'bar again'", - 'focus': True, - } - ] - } - ] - } - -two_windows_with_filters = \ - { - 'session_name': None, - 'windows': [ - { - 'window_name': 'foo', - 'start_directory': '/foo', - 'shell_command_before': [ - 'echo first before filter', - 'echo second before filter', - ], - 'shell_command_after': [ - 'echo first after filter', - 'echo second after filter', - ], - 'panes': [ - { - 'shell_command': "echo 'foo'" - }, - { - 'shell_command': "echo 'foo again'", - } - ] - } - ] - } - -two_windows_with_custom_command_options = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'foo', - 'start_directory': '/foo', - 'clear': True, - 'layout': 'tiled', - 'panes': [ - { - 'shell_command': "echo 'foo'", - }, - { - 'shell_command': "echo 'foo again'", - } - ] - }, - { - 'window_name': 'bar', - 'start_directory': '/bar', - 'panes': [ - { - 'shell_command': [ - "echo 'bar'", - "echo 'bar in an array'" - ], - 'target': 'bottom-right' - }, - { - 'shell_command': "echo 'bar again'", - 'focus': True, - } - ] - - } - ] - -} - -three_windows_within_a_session = { - 'session_name': 'my awesome session', - 'windows': [ - { - 'window_name': 'first window', - 'panes': [ - { - 'shell_command': "echo 'foo'" - }, - ] - }, - { - 'window_name': 'second window', - 'panes': [ - { - 'shell_command': "echo 'foo'" - }, - ] - }, - { - 'window_name': 'third window', - 'panes': [ - { - 'shell_command': "echo 'foo'" - }, - ] - }, - ] -} diff --git a/tests/fixtures/config_teamocil/test1.py b/tests/fixtures/config_teamocil/test1.py deleted file mode 100644 index f504344217..0000000000 --- a/tests/fixtures/config_teamocil/test1.py +++ /dev/null @@ -1,43 +0,0 @@ -from .._util import loadfixture - -teamocil_yaml = loadfixture('config_teamocil/test1.yaml') -teamocil_conf = { - 'windows': [{ - 'name': 'sample-two-panes', - 'root': '~/Code/sample/www', - 'layout': 'even-horizontal', - 'panes': [ - { - 'cmd': [ - 'pwd', - 'ls -la' - ] - }, - { - 'cmd': 'rails server --port 3000' - } - ] - }] -} - -expected = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'sample-two-panes', - 'layout': 'even-horizontal', - 'start_directory': '~/Code/sample/www', - 'panes': [ - { - 'shell_command': [ - 'pwd', - 'ls -la' - ] - }, - { - 'shell_command': 'rails server --port 3000' - } - ] - } - ] -} diff --git a/tests/fixtures/config_teamocil/test2.py b/tests/fixtures/config_teamocil/test2.py deleted file mode 100644 index 7c9c083098..0000000000 --- a/tests/fixtures/config_teamocil/test2.py +++ /dev/null @@ -1,41 +0,0 @@ -from .._util import loadfixture - -teamocil_yaml = loadfixture('config_teamocil/test2.yaml') -teamocil_dict = { - 'windows': [{ - 'name': 'sample-four-panes', - 'root': '~/Code/sample/www', - 'layout': 'tiled', - 'panes': [ - {'cmd': 'pwd'}, - {'cmd': 'pwd'}, - {'cmd': 'pwd'}, - {'cmd': 'pwd'}, - ] - }] -} - -expected = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'sample-four-panes', - 'layout': 'tiled', - 'start_directory': '~/Code/sample/www', - 'panes': [ - { - 'shell_command': 'pwd' - }, - { - 'shell_command': 'pwd' - }, - { - 'shell_command': 'pwd' - }, - { - 'shell_command': 'pwd' - }, - ] - } - ] -} diff --git a/tests/fixtures/config_teamocil/test3.py b/tests/fixtures/config_teamocil/test3.py deleted file mode 100644 index 6a33d07e97..0000000000 --- a/tests/fixtures/config_teamocil/test3.py +++ /dev/null @@ -1,56 +0,0 @@ -from .._util import loadfixture - -teamocil_yaml = loadfixture('config_teamocil/test3.yaml') - -teamocil_dict = { - 'windows': [{ - 'name': 'my-first-window', - 'root': '~/Projects/foo-www', - 'layout': 'even-vertical', - 'filters': { - 'before': 'rbenv local 2.0.0-p0', - 'after': 'echo \'I am done initializing this pane.\'' - }, - 'panes': [ - {'cmd': 'git status'}, - {'cmd': 'bundle exec rails server --port 40', - 'focus': True}, - {'cmd': [ - 'sudo service memcached start', - 'sudo service mongodb start', - ]} - ] - }] -} - -expected = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'my-first-window', - 'layout': 'even-vertical', - 'start_directory': "~/Projects/foo-www", - 'shell_command_before': 'rbenv local 2.0.0-p0', - 'shell_command_after': ( - 'echo ' - '\'I am done initializing this pane.\'' - ), - 'panes': [ - { - 'shell_command': 'git status' - }, - { - 'shell_command': 'bundle exec rails server --port 40', - 'focus': True - }, - { - 'shell_command': [ - 'sudo service memcached start', - 'sudo service mongodb start' - ] - } - ] - } - - ] -} diff --git a/tests/fixtures/config_teamocil/test4.py b/tests/fixtures/config_teamocil/test4.py deleted file mode 100644 index 0ef912b574..0000000000 --- a/tests/fixtures/config_teamocil/test4.py +++ /dev/null @@ -1,28 +0,0 @@ -from .._util import loadfixture - -teamocil_yaml = loadfixture('config_teamocil/test4.yaml') - -teamocil_dict = { - 'windows': [{ - 'name': 'erb-example', - 'root': "<%= ENV['MY_PROJECT_ROOT'] %>", - 'panes': [ - {'cmd': 'pwd'} - ] - }] -} - -expected = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'erb-example', - 'start_directory': "<%= ENV['MY_PROJECT_ROOT'] %>", - 'panes': [ - { - 'shell_command': 'pwd' - } - ] - } - ] -} diff --git a/tests/fixtures/config_tmuxinator/__init__.py b/tests/fixtures/config_tmuxinator/__init__.py deleted file mode 100644 index bbdbf699ad..0000000000 --- a/tests/fixtures/config_tmuxinator/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test1, test2, test3 # noqa diff --git a/tests/fixtures/config_tmuxinator/test1.py b/tests/fixtures/config_tmuxinator/test1.py deleted file mode 100644 index 46fb53296d..0000000000 --- a/tests/fixtures/config_tmuxinator/test1.py +++ /dev/null @@ -1,49 +0,0 @@ -from .._util import loadfixture - - -tmuxinator_yaml = loadfixture('config_tmuxinator/test1.yaml') -tmuxinator_dict = { - 'windows': [ - { - 'editor': { - 'layout': 'main-vertical', - 'panes': [ - 'vim', - 'guard' - ] - } - }, - { - 'server': 'bundle exec rails s', - }, - { - 'logs': 'tail -f logs/development.log' - } - ] -} - -expected = { - 'session_name': None, - 'windows': [ - { - 'window_name': 'editor', - 'layout': 'main-vertical', - 'panes': [ - 'vim', - 'guard' - ] - }, - { - 'window_name': 'server', - 'panes': [ - 'bundle exec rails s' - ] - }, - { - 'window_name': 'logs', - 'panes': [ - 'tail -f logs/development.log' - ] - } - ] -} diff --git a/tests/fixtures/config_tmuxinator/test2.py b/tests/fixtures/config_tmuxinator/test2.py deleted file mode 100644 index 535d7ddddf..0000000000 --- a/tests/fixtures/config_tmuxinator/test2.py +++ /dev/null @@ -1,132 +0,0 @@ -from .._util import loadfixture - - -tmuxinator_yaml = loadfixture('config_tmuxinator/test2.yaml') - -tmuxinator_dict = { - 'project_name': 'sample', - 'project_root': '~/test', - 'socket_name': 'foo', - 'pre': 'sudo /etc/rc.d/mysqld start', - 'rbenv': '2.0.0-p247', - 'cli_args': '-f ~/.tmux.mac.conf', - 'tabs': [ - { - 'editor': { - 'pre': [ - 'echo "I get run in each pane, ' - 'before each pane command!"', - None - ], - 'layout': 'main-vertical', - 'panes': [ - 'vim', - None, - 'top' - ] - } - }, - {'shell': 'git pull', }, - { - 'guard': { - 'layout': 'tiled', - 'pre': [ - 'echo "I get run in each pane."', - 'echo "Before each pane command!"' - ], - 'panes': [ - None, - None, - None - ] - } - }, - {'database': 'bundle exec rails db'}, - {'server': 'bundle exec rails s'}, - {'logs': 'tail -f log/development.log'}, - {'console': 'bundle exec rails c'}, - {'capistrano': None}, - {'server': 'ssh user@example.com'} - ] -} - -expected = { - 'session_name': 'sample', - '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' - ], - 'windows': [ - { - 'window_name': 'editor', - 'shell_command_before': [ - 'echo "I get run in each pane, before each pane command!"', - None - ], - 'layout': 'main-vertical', - 'panes': [ - 'vim', - None, - 'top' - ] - }, - { - 'window_name': 'shell', - 'panes': [ - 'git pull' - ] - }, - { - 'window_name': 'guard', - 'layout': 'tiled', - 'shell_command_before': [ - 'echo "I get run in each pane."', - 'echo "Before each pane command!"' - ], - 'panes': [ - None, - None, - None - ] - }, - { - 'window_name': 'database', - 'panes': [ - 'bundle exec rails db' - ] - }, - { - 'window_name': 'server', - 'panes': [ - 'bundle exec rails s' - ] - }, - { - 'window_name': 'logs', - 'panes': [ - 'tail -f log/development.log' - ] - }, - { - 'window_name': 'console', - 'panes': [ - 'bundle exec rails c' - ] - }, - { - 'window_name': 'capistrano', - 'panes': [ - None - ] - }, - { - 'window_name': 'server', - 'panes': [ - 'ssh user@example.com' - ] - } - ] -} diff --git a/tests/fixtures/config_tmuxinator/test3.py b/tests/fixtures/config_tmuxinator/test3.py deleted file mode 100644 index 601f862095..0000000000 --- a/tests/fixtures/config_tmuxinator/test3.py +++ /dev/null @@ -1,140 +0,0 @@ -from .._util import loadfixture - - -tmuxinator_yaml = loadfixture('config_tmuxinator/test3.yaml') - -tmuxinator_dict = { - 'name': 'sample', - 'root': '~/test', - 'socket_name': 'foo', - 'tmux_options': '-f ~/.tmux.mac.conf', - 'pre': 'sudo /etc/rc.d/mysqld start', - 'pre_window': 'rbenv shell 2.0.0-p247', - 'windows': [ - { - 'editor': { - 'pre': [ - 'echo "I get run in each pane, ' - 'before each pane command!"', - None - ], - 'layout': 'main-vertical', - 'root': '~/test/editor', - 'panes': [ - 'vim', - None, - 'top' - ] - } - }, - { - 'shell': [ - 'git pull', - 'git merge' - ] - }, - { - 'guard': { - 'layout': 'tiled', - 'pre': [ - 'echo "I get run in each pane."', - 'echo "Before each pane command!"' - ], - 'panes': [ - None, - None, - None - ] - } - }, - {'database': 'bundle exec rails db'}, - {'server': 'bundle exec rails s'}, - {'logs': 'tail -f log/development.log'}, - {'console': 'bundle exec rails c'}, - {'capistrano': None}, - {'server': 'ssh user@example.com'} - ] -} - -expected = { - 'session_name': 'sample', - 'socket_name': 'foo', - 'start_directory': '~/test', - 'config': '~/.tmux.mac.conf', - 'shell_command': 'sudo /etc/rc.d/mysqld start', - 'shell_command_before': [ - 'rbenv shell 2.0.0-p247' - ], - 'windows': [ - { - 'window_name': 'editor', - 'shell_command_before': [ - 'echo "I get run in each pane, before each pane command!"', - None - ], - 'layout': 'main-vertical', - 'start_directory': '~/test/editor', - 'panes': [ - 'vim', - None, - 'top' - ] - }, - { - 'window_name': 'shell', - 'panes': [ - 'git pull', - 'git merge' - ] - }, - { - 'window_name': 'guard', - 'layout': 'tiled', - 'shell_command_before': [ - 'echo "I get run in each pane."', - 'echo "Before each pane command!"' - ], - 'panes': [ - None, - None, - None - ] - }, - { - 'window_name': 'database', - 'panes': [ - 'bundle exec rails db' - ] - }, - { - 'window_name': 'server', - 'panes': [ - 'bundle exec rails s' - ] - }, - { - 'window_name': 'logs', - 'panes': [ - 'tail -f log/development.log' - ] - }, - { - 'window_name': 'console', - 'panes': [ - 'bundle exec rails c' - ] - }, - { - 'window_name': 'capistrano', - 'panes': [ - None - ] - }, - { - 'window_name': 'server', - 'panes': [ - 'ssh user@example.com' - ] - } - ] -} diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py new file mode 100644 index 0000000000..1ec7c59fd5 --- /dev/null +++ b/tests/fixtures/import_teamocil/__init__.py @@ -0,0 +1,5 @@ +"""Teamocil data fixtures for import_teamocil tests.""" + +from __future__ import annotations + +from . import layouts, test1, test2, test3, test4 diff --git a/tests/fixtures/import_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py new file mode 100644 index 0000000000..6e3e06bf7f --- /dev/null +++ b/tests/fixtures/import_teamocil/layouts.py @@ -0,0 +1,171 @@ +"""Teamocil data fixtures for import_teamocil tests, for layout testing.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml_file = test_utils.get_workspace_file("import_teamocil/layouts.yaml") +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/layouts.yaml") + +teamocil_dict = { + "two-windows": { + "windows": [ + { + "name": "foo", + "clear": True, + "root": "/foo", + "layout": "tiled", + "panes": [{"cmd": "echo 'foo'"}, {"cmd": "echo 'foo again'"}], + }, + { + "name": "bar", + "root": "/bar", + "splits": [ + { + "cmd": ["echo 'bar'", "echo 'bar in an array'"], + "target": "bottom-right", + }, + {"cmd": "echo 'bar again'", "focus": True, "width": 50}, + ], + }, + ], + }, + "two-windows-with-filters": { + "windows": [ + { + "name": "foo", + "root": "/foo", + "filters": { + "before": ["echo first before filter", "echo second before filter"], + "after": ["echo first after filter", "echo second after filter"], + }, + "panes": [ + {"cmd": "echo 'foo'"}, + {"cmd": "echo 'foo again'", "width": 50}, + ], + }, + ], + }, + "two-windows-with-custom-command-options": { + "windows": [ + { + "name": "foo", + "cmd_separator": "\n", + "with_env_var": False, + "clear": True, + "root": "/foo", + "layout": "tiled", + "panes": [{"cmd": "echo 'foo'"}, {"cmd": "echo 'foo again'"}], + }, + { + "name": "bar", + "cmd_separator": " && ", + "with_env_var": True, + "root": "/bar", + "splits": [ + { + "cmd": ["echo 'bar'", "echo 'bar in an array'"], + "target": "bottom-right", + }, + {"cmd": "echo 'bar again'", "focus": True, "width": 50}, + ], + }, + ], + }, + "three-windows-within-a-session": { + "session": { + "name": "my awesome session", + "windows": [ + {"name": "first window", "panes": [{"cmd": "echo 'foo'"}]}, + {"name": "second window", "panes": [{"cmd": "echo 'foo'"}]}, + {"name": "third window", "panes": [{"cmd": "echo 'foo'"}]}, + ], + }, + }, +} + + +two_windows = { + "session_name": None, + "windows": [ + { + "window_name": "foo", + "start_directory": "/foo", + "clear": True, + "layout": "tiled", + "panes": [ + {"shell_command": "echo 'foo'"}, + {"shell_command": "echo 'foo again'"}, + ], + }, + { + "window_name": "bar", + "start_directory": "/bar", + "panes": [ + { + "shell_command": ["echo 'bar'", "echo 'bar in an array'"], + "target": "bottom-right", + }, + {"shell_command": "echo 'bar again'", "focus": True}, + ], + }, + ], +} + +two_windows_with_filters = { + "session_name": None, + "windows": [ + { + "window_name": "foo", + "start_directory": "/foo", + "shell_command_before": [ + "echo first before filter", + "echo second before filter", + ], + "shell_command_after": [ + "echo first after filter", + "echo second after filter", + ], + "panes": [ + {"shell_command": "echo 'foo'"}, + {"shell_command": "echo 'foo again'"}, + ], + }, + ], +} + +two_windows_with_custom_command_options = { + "session_name": None, + "windows": [ + { + "window_name": "foo", + "start_directory": "/foo", + "clear": True, + "layout": "tiled", + "panes": [ + {"shell_command": "echo 'foo'"}, + {"shell_command": "echo 'foo again'"}, + ], + }, + { + "window_name": "bar", + "start_directory": "/bar", + "panes": [ + { + "shell_command": ["echo 'bar'", "echo 'bar in an array'"], + "target": "bottom-right", + }, + {"shell_command": "echo 'bar again'", "focus": True}, + ], + }, + ], +} + +three_windows_within_a_session = { + "session_name": "my awesome session", + "windows": [ + {"window_name": "first window", "panes": [{"shell_command": "echo 'foo'"}]}, + {"window_name": "second window", "panes": [{"shell_command": "echo 'foo'"}]}, + {"window_name": "third window", "panes": [{"shell_command": "echo 'foo'"}]}, + ], +} diff --git a/tests/fixtures/config_teamocil/layouts.yaml b/tests/fixtures/import_teamocil/layouts.yaml similarity index 100% rename from tests/fixtures/config_teamocil/layouts.yaml rename to tests/fixtures/import_teamocil/layouts.yaml diff --git a/tests/fixtures/import_teamocil/test1.py b/tests/fixtures/import_teamocil/test1.py new file mode 100644 index 0000000000..8e2065fec2 --- /dev/null +++ b/tests/fixtures/import_teamocil/test1.py @@ -0,0 +1,32 @@ +"""Teamocil data fixtures for import_teamocil tests, 1st test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test1.yaml") +teamocil_conf = { + "windows": [ + { + "name": "sample-two-panes", + "root": "~/Code/sample/www", + "layout": "even-horizontal", + "panes": [{"cmd": ["pwd", "ls -la"]}, {"cmd": "rails server --port 3000"}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "sample-two-panes", + "layout": "even-horizontal", + "start_directory": "~/Code/sample/www", + "panes": [ + {"shell_command": ["pwd", "ls -la"]}, + {"shell_command": "rails server --port 3000"}, + ], + }, + ], +} diff --git a/tests/fixtures/config_teamocil/test1.yaml b/tests/fixtures/import_teamocil/test1.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test1.yaml rename to tests/fixtures/import_teamocil/test1.yaml diff --git a/tests/fixtures/import_teamocil/test2.py b/tests/fixtures/import_teamocil/test2.py new file mode 100644 index 0000000000..0353a0edbf --- /dev/null +++ b/tests/fixtures/import_teamocil/test2.py @@ -0,0 +1,34 @@ +"""Teamocil data fixtures for import_teamocil tests, 2nd test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test2.yaml") +teamocil_dict = { + "windows": [ + { + "name": "sample-four-panes", + "root": "~/Code/sample/www", + "layout": "tiled", + "panes": [{"cmd": "pwd"}, {"cmd": "pwd"}, {"cmd": "pwd"}, {"cmd": "pwd"}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "sample-four-panes", + "layout": "tiled", + "start_directory": "~/Code/sample/www", + "panes": [ + {"shell_command": "pwd"}, + {"shell_command": "pwd"}, + {"shell_command": "pwd"}, + {"shell_command": "pwd"}, + ], + }, + ], +} diff --git a/tests/fixtures/config_teamocil/test2.yaml b/tests/fixtures/import_teamocil/test2.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test2.yaml rename to tests/fixtures/import_teamocil/test2.yaml diff --git a/tests/fixtures/import_teamocil/test3.py b/tests/fixtures/import_teamocil/test3.py new file mode 100644 index 0000000000..1bcd32fef1 --- /dev/null +++ b/tests/fixtures/import_teamocil/test3.py @@ -0,0 +1,49 @@ +"""Teamocil data fixtures for import_teamocil tests, 3rd test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test3.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "my-first-window", + "root": "~/Projects/foo-www", + "layout": "even-vertical", + "filters": { + "before": "rbenv local 2.0.0-p0", + "after": "echo 'I am done initializing this pane.'", + }, + "panes": [ + {"cmd": "git status"}, + {"cmd": "bundle exec rails server --port 40", "focus": True}, + {"cmd": ["sudo service memcached start", "sudo service mongodb start"]}, + ], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "my-first-window", + "layout": "even-vertical", + "start_directory": "~/Projects/foo-www", + "shell_command_before": "rbenv local 2.0.0-p0", + "shell_command_after": ("echo 'I am done initializing this pane.'"), + "panes": [ + {"shell_command": "git status"}, + {"shell_command": "bundle exec rails server --port 40", "focus": True}, + { + "shell_command": [ + "sudo service memcached start", + "sudo service mongodb start", + ], + }, + ], + }, + ], +} diff --git a/tests/fixtures/config_teamocil/test3.yaml b/tests/fixtures/import_teamocil/test3.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test3.yaml rename to tests/fixtures/import_teamocil/test3.yaml diff --git a/tests/fixtures/import_teamocil/test4.py b/tests/fixtures/import_teamocil/test4.py new file mode 100644 index 0000000000..1837abf508 --- /dev/null +++ b/tests/fixtures/import_teamocil/test4.py @@ -0,0 +1,28 @@ +"""Teamocil data fixtures for import_teamocil tests, 4th test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test4.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "erb-example", + "root": "<%= ENV['MY_PROJECT_ROOT'] %>", + "panes": [{"cmd": "pwd"}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "erb-example", + "start_directory": "<%= ENV['MY_PROJECT_ROOT'] %>", + "panes": [{"shell_command": "pwd"}], + }, + ], +} diff --git a/tests/fixtures/config_teamocil/test4.yaml b/tests/fixtures/import_teamocil/test4.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test4.yaml rename to tests/fixtures/import_teamocil/test4.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py new file mode 100644 index 0000000000..84508e0405 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -0,0 +1,5 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests.""" + +from __future__ import annotations + +from . import test1, test2, test3 diff --git a/tests/fixtures/import_tmuxinator/test1.py b/tests/fixtures/import_tmuxinator/test1.py new file mode 100644 index 0000000000..7e08f976d0 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test1.py @@ -0,0 +1,23 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 1st dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test1.yaml") +tmuxinator_dict = { + "windows": [ + {"editor": {"layout": "main-vertical", "panes": ["vim", "guard"]}}, + {"server": "bundle exec rails s"}, + {"logs": "tail -f logs/development.log"}, + ], +} + +expected = { + "session_name": None, + "windows": [ + {"window_name": "editor", "layout": "main-vertical", "panes": ["vim", "guard"]}, + {"window_name": "server", "panes": ["bundle exec rails s"]}, + {"window_name": "logs", "panes": ["tail -f logs/development.log"]}, + ], +} diff --git a/tests/fixtures/config_tmuxinator/test1.yaml b/tests/fixtures/import_tmuxinator/test1.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test1.yaml rename to tests/fixtures/import_tmuxinator/test1.yaml diff --git a/tests/fixtures/import_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py new file mode 100644 index 0000000000..97d923a912 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -0,0 +1,80 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 2nd dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test2.yaml") + +tmuxinator_dict = { + "project_name": "sample", + "project_root": "~/test", + "socket_name": "foo", + "pre": "sudo /etc/rc.d/mysqld start", + "rbenv": "2.0.0-p247", + "cli_args": "-f ~/.tmux.mac.conf", + "tabs": [ + { + "editor": { + "pre": [ + 'echo "I get run in each pane, before each pane command!"', + None, + ], + "layout": "main-vertical", + "panes": ["vim", None, "top"], + }, + }, + {"shell": "git pull"}, + { + "guard": { + "layout": "tiled", + "pre": [ + 'echo "I get run in each pane."', + 'echo "Before each pane command!"', + ], + "panes": [None, None, None], + }, + }, + {"database": "bundle exec rails db"}, + {"server": "bundle exec rails s"}, + {"logs": "tail -f log/development.log"}, + {"console": "bundle exec rails c"}, + {"capistrano": None}, + {"server": "ssh user@example.com"}, + ], +} + +expected = { + "session_name": "sample", + "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"], + "windows": [ + { + "window_name": "editor", + "shell_command_before": [ + 'echo "I get run in each pane, before each pane command!"', + None, + ], + "layout": "main-vertical", + "panes": ["vim", None, "top"], + }, + {"window_name": "shell", "panes": ["git pull"]}, + { + "window_name": "guard", + "layout": "tiled", + "shell_command_before": [ + 'echo "I get run in each pane."', + 'echo "Before each pane command!"', + ], + "panes": [None, None, None], + }, + {"window_name": "database", "panes": ["bundle exec rails db"]}, + {"window_name": "server", "panes": ["bundle exec rails s"]}, + {"window_name": "logs", "panes": ["tail -f log/development.log"]}, + {"window_name": "console", "panes": ["bundle exec rails c"]}, + {"window_name": "capistrano", "panes": [None]}, + {"window_name": "server", "panes": ["ssh user@example.com"]}, + ], +} diff --git a/tests/fixtures/config_tmuxinator/test2.yaml b/tests/fixtures/import_tmuxinator/test2.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test2.yaml rename to tests/fixtures/import_tmuxinator/test2.yaml diff --git a/tests/fixtures/import_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py new file mode 100644 index 0000000000..86ebd22c16 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -0,0 +1,83 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 3rd dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test3.yaml") + +tmuxinator_dict = { + "name": "sample", + "root": "~/test", + "socket_name": "foo", + "tmux_options": "-f ~/.tmux.mac.conf", + "pre": "sudo /etc/rc.d/mysqld start", + "pre_window": "rbenv shell 2.0.0-p247", + "windows": [ + { + "editor": { + "pre": [ + 'echo "I get run in each pane, before each pane command!"', + None, + ], + "layout": "main-vertical", + "root": "~/test/editor", + "panes": ["vim", None, "top"], + }, + }, + {"shell": ["git pull", "git merge"]}, + { + "guard": { + "layout": "tiled", + "pre": [ + 'echo "I get run in each pane."', + 'echo "Before each pane command!"', + ], + "panes": [None, None, None], + }, + }, + {"database": "bundle exec rails db"}, + {"server": "bundle exec rails s"}, + {"logs": "tail -f log/development.log"}, + {"console": "bundle exec rails c"}, + {"capistrano": None}, + {"server": "ssh user@example.com"}, + ], +} + +expected = { + "session_name": "sample", + "socket_name": "foo", + "start_directory": "~/test", + "config": "~/.tmux.mac.conf", + "shell_command": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["rbenv shell 2.0.0-p247"], + "windows": [ + { + "window_name": "editor", + "shell_command_before": [ + 'echo "I get run in each pane, before each pane command!"', + None, + ], + "layout": "main-vertical", + "start_directory": "~/test/editor", + "panes": ["vim", None, "top"], + }, + {"window_name": "shell", "panes": ["git pull", "git merge"]}, + { + "window_name": "guard", + "layout": "tiled", + "shell_command_before": [ + 'echo "I get run in each pane."', + 'echo "Before each pane command!"', + ], + "panes": [None, None, None], + }, + {"window_name": "database", "panes": ["bundle exec rails db"]}, + {"window_name": "server", "panes": ["bundle exec rails s"]}, + {"window_name": "logs", "panes": ["tail -f log/development.log"]}, + {"window_name": "console", "panes": ["bundle exec rails c"]}, + {"window_name": "capistrano", "panes": [None]}, + {"window_name": "server", "panes": ["ssh user@example.com"]}, + ], +} diff --git a/tests/fixtures/config_tmuxinator/test3.yaml b/tests/fixtures/import_tmuxinator/test3.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test3.yaml rename to tests/fixtures/import_tmuxinator/test3.yaml diff --git a/tests/fixtures/pluginsystem/__init__.py b/tests/fixtures/pluginsystem/__init__.py new file mode 100644 index 0000000000..e4aa4bbf4d --- /dev/null +++ b/tests/fixtures/pluginsystem/__init__.py @@ -0,0 +1 @@ +"""Test data for tmuxp plugin system.""" diff --git a/tests/fixtures/pluginsystem/partials/__init__.py b/tests/fixtures/pluginsystem/partials/__init__.py new file mode 100644 index 0000000000..793b523735 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/__init__.py @@ -0,0 +1 @@ +"""Tmuxp tests for plugins.""" diff --git a/tests/fixtures/pluginsystem/partials/_types.py b/tests/fixtures/pluginsystem/partials/_types.py new file mode 100644 index 0000000000..cf5c5c0380 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/_types.py @@ -0,0 +1,46 @@ +"""Internal, :const:`typing.TYPE_CHECKING` scoped :term:`type annotations `. + +These are _not_ to be imported at runtime as `typing_extensions` is not +bundled with tmuxp. Usage example: + +>>> import typing as t + +>>> if t.TYPE_CHECKING: +... from tmuxp.fixtures.pluginsystem.partials._types import PluginConfigSchema +... +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from typing_extensions import NotRequired, TypedDict +else: + # Fallback for runtime, though this module should not be imported at runtime + try: + from typing_extensions import NotRequired, TypedDict + except ImportError: + # Create dummy classes for runtime + NotRequired = t.Any # type: ignore[misc, assignment] + TypedDict = type # type: ignore[assignment, misc] + + +class PluginTestConfigSchema(TypedDict): + """Same as PluginConfigSchema, but with tmux, libtmux, and tmuxp version.""" + + tmux_version: NotRequired[str] + libtmux_version: NotRequired[str] + tmuxp_version: NotRequired[str] + + # Normal keys + plugin_name: NotRequired[str] + tmux_min_version: NotRequired[str] + tmux_max_version: NotRequired[str] + tmux_version_incompatible: NotRequired[list[str]] + libtmux_min_version: NotRequired[str] + libtmux_max_version: NotRequired[str] + libtmux_version_incompatible: NotRequired[list[str]] + tmuxp_min_version: NotRequired[str] + tmuxp_max_version: NotRequired[str] + tmuxp_version_incompatible: NotRequired[list[str]] diff --git a/tests/fixtures/pluginsystem/partials/all_pass.py b/tests/fixtures/pluginsystem/partials/all_pass.py new file mode 100644 index 0000000000..293d70bef1 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/all_pass.py @@ -0,0 +1,31 @@ +"""Tmuxp test plugin with version constraints guaranteed to pass.""" + +from __future__ import annotations + +import typing as t + +from .test_plugin_helpers import MyTestTmuxpPlugin + +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + + +class AllVersionPassPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin with config constraints guaranteed to validate.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmuxp-plugin-my-tmuxp-plugin", + "tmux_min_version": "1.8", + "tmux_max_version": "100.0", + "tmux_version_incompatible": ["2.3"], + "libtmux_min_version": "0.8.3", + "libtmux_max_version": "100.0", + "libtmux_version_incompatible": ["0.7.1"], + "tmuxp_min_version": "1.7.0", + "tmuxp_max_version": "100.0.0", + "tmuxp_version_incompatible": ["1.5.6"], + "tmux_version": "3.0", + "tmuxp_version": "1.7.0", + } + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py b/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py new file mode 100644 index 0000000000..e69efaa3f0 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py @@ -0,0 +1,46 @@ +"""Fixtures for tmuxp plugins for libtmux version exceptions.""" + +from __future__ import annotations + +import typing as t + +from .test_plugin_helpers import MyTestTmuxpPlugin + +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + + +class LibtmuxVersionFailMinPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when libtmux below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "libtmux-min-version-fail", + "libtmux_min_version": "0.8.3", + "libtmux_version": "0.7.0", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class LibtmuxVersionFailMaxPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when libtmux above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "libtmux-max-version-fail", + "libtmux_max_version": "3.0", + "libtmux_version": "3.5", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class LibtmuxVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when libtmux version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "libtmux-incompatible-version-fail", + "libtmux_version_incompatible": ["0.7.1"], + "libtmux_version": "0.7.1", + } + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py b/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py new file mode 100644 index 0000000000..d73674cf3f --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py @@ -0,0 +1,42 @@ +"""Tmuxp test plugin for asserting version constraints.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from typing_extensions import Unpack + + from tmuxp._internal.types import PluginConfigSchema + + from ._types import PluginTestConfigSchema + + +class MyTestTmuxpPlugin(TmuxpPlugin): + """Base class for testing tmuxp plugins with version constraints.""" + + def __init__(self, **config: Unpack[PluginTestConfigSchema]) -> None: + assert isinstance(config, dict) + tmux_version = config.pop("tmux_version", None) + libtmux_version = config.pop("libtmux_version", None) + tmuxp_version = config.pop("tmuxp_version", None) + + t.cast("PluginConfigSchema", config) + + assert "tmux_version" not in config + + # tests/fixtures/pluginsystem/partials/test_plugin_helpers.py:24: error: Extra + # argument "tmux_version" from **args for "__init__" of "TmuxpPlugin" [misc] + super().__init__(**config) # type:ignore + + # WARNING! This should not be done in anything but a test + if tmux_version: + self.version_constraints["tmux"]["version"] = tmux_version + if libtmux_version: + self.version_constraints["libtmux"]["version"] = libtmux_version + if tmuxp_version: + self.version_constraints["tmuxp"]["version"] = tmuxp_version + + self._version_check() diff --git a/tests/fixtures/pluginsystem/partials/tmux_version_fail.py b/tests/fixtures/pluginsystem/partials/tmux_version_fail.py new file mode 100644 index 0000000000..deaa89d2cf --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/tmux_version_fail.py @@ -0,0 +1,47 @@ +"""Fixtures for tmuxp plugins for tmux version exceptions.""" + +from __future__ import annotations + +import typing as t + +from .test_plugin_helpers import MyTestTmuxpPlugin + +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + + +class TmuxVersionFailMinPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmux below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmux-min-version-fail", + "tmux_min_version": "1.8", + "tmux_version": "1.7", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class TmuxVersionFailMaxPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmux above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmux-max-version-fail", + "tmux_max_version": "3.0", + "tmux_version": "3.5", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class TmuxVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmux version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmux-incompatible-version-fail", + "tmux_version_incompatible": ["2.3"], + "tmux_version": "2.3", + } + + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py b/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py new file mode 100644 index 0000000000..aa5b8e7783 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py @@ -0,0 +1,46 @@ +"""Fixtures for tmuxp plugins for tmuxp version exceptions.""" + +from __future__ import annotations + +import typing as t + +from .test_plugin_helpers import MyTestTmuxpPlugin + +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + + +class TmuxpVersionFailMinPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmuxp below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmuxp-min-version-fail", + "tmuxp_min_version": "1.7.0", + "tmuxp_version": "1.6.3", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class TmuxpVersionFailMaxPlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmuxp above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmuxp-max-version-fail", + "tmuxp_max_version": "2.0.0", + "tmuxp_version": "2.5", + } + MyTestTmuxpPlugin.__init__(self, **config) + + +class TmuxpVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): + """Tmuxp plugin that fails when tmuxp version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { + "plugin_name": "tmuxp-incompatible-version-fail", + "tmuxp_version_incompatible": ["1.5.0"], + "tmuxp_version": "1.5.0", + } + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml new file mode 100644 index 0000000000..27beec81b1 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_awf" +version = "0.0.2" +description = "A tmuxp plugin to test after_window_finished part of the tmuxp plugin system" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py new file mode 100644 index 0000000000..9cbdb91f11 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin that runs after window creation completions.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py new file mode 100644 index 0000000000..a3b323dc65 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py @@ -0,0 +1,28 @@ +"""Tmuxp example plugin for after_window_finished.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from libtmux.window import Window + + +class PluginAfterWindowFinished(TmuxpPlugin): + """Tmuxp plugin that runs after window creation completes.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" + + def after_window_finished(self, window: Window) -> None: + """Run hook after window creation completed.""" + if window.name == "editor": + window.rename_window("plugin_test_awf") + elif window.name == "awf_mw_test": + window.rename_window("plugin_test_awf_mw") + elif window.name == "awf_mw_test_2": + window.rename_window("plugin_test_awf_mw_2") + elif window.name == "mp_test_owc": + window.rename_window("mp_test_awf") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml new file mode 100644 index 0000000000..3e624b80f4 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_bs" +version = "0.0.2" +description = "A tmuxp plugin to test before_script part of the tmuxp plugin system" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py new file mode 100644 index 0000000000..4329e60aae --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module that hooks in before_script, if declared.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py new file mode 100644 index 0000000000..9bc64c9e37 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py @@ -0,0 +1,21 @@ +"""Tmux plugin that runs before_script, if it is declared in configuration.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class PluginBeforeScript(TmuxpPlugin): + """Tmuxp plugin that runs before_script.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" + + def before_script(self, session: Session) -> None: + """Run hook during before_script, if it is declared.""" + session.rename_session("plugin_test_bs") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml new file mode 100644 index 0000000000..f469ebfb44 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_bwb" +version = "0.0.2" +description = "A tmuxp plugin to test before_workspace_build part of the tmuxp plugin system" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py new file mode 100644 index 0000000000..98ca0ca0fc --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin that runs before workspace builder inits.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py new file mode 100644 index 0000000000..b564ec5829 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py @@ -0,0 +1,21 @@ +"""Tmuxp example plugin for before_worksplace_builder.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class PluginBeforeWorkspaceBuilder(TmuxpPlugin): + """Tmuxp plugin that runs before worksplace builder starts.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" + + def before_workspace_builder(self, session: Session) -> None: + """Run hook before workspace builder begins.""" + session.rename_session("plugin_test_bwb") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml new file mode 100644 index 0000000000..dad2701978 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_fail" +version = "0.1.0" +description = "A test plugin designed to fail to test the cli" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py new file mode 100644 index 0000000000..2afcb8fce8 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py @@ -0,0 +1 @@ +"""Tmuxp plugin test, that is destined for failure.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py new file mode 100644 index 0000000000..9be75a6e93 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py @@ -0,0 +1,21 @@ +"""Tmuxp example plugin that fails on initialization.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from tmuxp._internal.types import PluginConfigSchema + + +class PluginFailVersion(TmuxpPlugin): + """A tmuxp plugin that is doomed to fail. DOOMED.""" + + def __init__(self) -> None: + config: PluginConfigSchema = { + "plugin_name": "tmuxp-plugin-fail-version", + "tmuxp_max_version": "0.0.0", + } + TmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml new file mode 100644 index 0000000000..485a644297 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_owc" +version = "0.0.2" +description = "A tmuxp plugin to test on_window_create part of the tmuxp plugin system" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py new file mode 100644 index 0000000000..f649064840 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module for hooks on window creation.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py new file mode 100644 index 0000000000..acb9268725 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py @@ -0,0 +1,28 @@ +"""Tmuxp example plugin for on_window_create.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from libtmux.window import Window + + +class PluginOnWindowCreate(TmuxpPlugin): + """Tmuxp plugin to test custom functionality on window creation.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" + + def on_window_create(self, window: Window) -> None: + """Apply hook that runs for tmux on session reattach.""" + if window.name == "editor": + window.rename_window("plugin_test_owc") + elif window.name == "owc_mw_test": + window.rename_window("plugin_test_owc_mw") + elif window.name == "owc_mw_test_2": + window.rename_window("plugin_test_owc_mw_2") + elif window.name == "mp_test": + window.rename_window("mp_test_owc") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml new file mode 100644 index 0000000000..a212e10ad8 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "tmuxp_test_plugin_r" +version = "0.0.2" +description = "A tmuxp plugin to test reattach part of the tmuxp plugin system" +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py new file mode 100644 index 0000000000..6e01504fd6 --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module for reattaching sessions.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py new file mode 100644 index 0000000000..396afabf5a --- /dev/null +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py @@ -0,0 +1,21 @@ +"""Tmuxp example plugin for reattaching session.""" + +from __future__ import annotations + +import typing as t + +from tmuxp.plugin import TmuxpPlugin + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class PluginReattach(TmuxpPlugin): + """Tmuxp plugin to test renaming session on reattach.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" + + def reattach(self, session: Session) -> None: + """Apply hook that runs for tmux on session reattach.""" + session.rename_session("plugin_test_r") diff --git a/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml b/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml new file mode 100644 index 0000000000..e18607b380 --- /dev/null +++ b/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml @@ -0,0 +1,12 @@ +session_name: many-windows-issue +windows: +- window_name: moo + layout: main-horizontal + panes: + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/script_failed.sh b/tests/fixtures/script_failed.sh index 2089694277..82d5b6bb97 100755 --- a/tests/fixtures/script_failed.sh +++ b/tests/fixtures/script_failed.sh @@ -2,7 +2,7 @@ echoerr() { echo "$@" 1>&2; } -echoerr An error has occured +echoerr An error has occurred exit 113 # Will return 113 to shell. # To verify this, type "echo $?" after script terminates. diff --git a/tests/fixtures/structures.py b/tests/fixtures/structures.py new file mode 100644 index 0000000000..5c7587e4ce --- /dev/null +++ b/tests/fixtures/structures.py @@ -0,0 +1,19 @@ +"""Typings / structures for tmuxp fixtures.""" + +from __future__ import annotations + +import dataclasses +import typing as t + + +@dataclasses.dataclass +class WorkspaceTestData: + """Workspace data fixtures for tmuxp tests.""" + + expand1: t.Any + expand2: t.Any + expand_blank: t.Any + sample_workspace: t.Any + shell_command_before: t.Any + shell_command_before_session: t.Any + trickle: t.Any diff --git a/tests/fixtures/tmux/tmux.conf b/tests/fixtures/tmux/tmux.conf new file mode 100644 index 0000000000..ed2abd7ef9 --- /dev/null +++ b/tests/fixtures/tmux/tmux.conf @@ -0,0 +1 @@ +display-message "Hello World" diff --git a/tests/fixtures/utils.py b/tests/fixtures/utils.py new file mode 100644 index 0000000000..23c4ac26fd --- /dev/null +++ b/tests/fixtures/utils.py @@ -0,0 +1,38 @@ +"""Utility functions for tmuxp fixtures.""" + +from __future__ import annotations + +import pathlib + +from tests.constants import FIXTURE_PATH + + +def get_workspace_file( + file: str | pathlib.Path, +) -> pathlib.Path: + """Return fixture data, relative to __file__.""" + if isinstance(file, str): + file = pathlib.Path(file) + + return FIXTURE_PATH / file + + +def read_workspace_file( + file: pathlib.Path | str, +) -> str: + """Return fixture data, relative to __file__.""" + if isinstance(file, str): + file = pathlib.Path(file) + + return get_workspace_file(file).open().read() + + +def write_config( + config_path: pathlib.Path, + filename: str, + content: str, +) -> pathlib.Path: + """Write configuration content to file.""" + config = config_path / filename + config.write_text(content, encoding="utf-8") + return config diff --git a/tests/fixtures/workspace/__init__.py b/tests/fixtures/workspace/__init__.py new file mode 100644 index 0000000000..b097ef9cb6 --- /dev/null +++ b/tests/fixtures/workspace/__init__.py @@ -0,0 +1,13 @@ +"""Workspace data fixtures for tmuxp tests.""" + +from __future__ import annotations + +from . import ( + expand1, + expand2, + expand_blank, + sample_workspace, + shell_command_before, + shell_command_before_session, + trickle, +) diff --git a/tests/fixtures/workspacebuilder/config_script_completes.yaml b/tests/fixtures/workspace/builder/config_script_completes.yaml similarity index 65% rename from tests/fixtures/workspacebuilder/config_script_completes.yaml rename to tests/fixtures/workspace/builder/config_script_completes.yaml index baa539f06a..3772ee8aca 100644 --- a/tests/fixtures/workspacebuilder/config_script_completes.yaml +++ b/tests/fixtures/workspace/builder/config_script_completes.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace before_script: {script_complete} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/config_script_fails.yaml b/tests/fixtures/workspace/builder/config_script_fails.yaml similarity index 66% rename from tests/fixtures/workspacebuilder/config_script_fails.yaml rename to tests/fixtures/workspace/builder/config_script_fails.yaml index 3a317d2495..25b3b899d4 100644 --- a/tests/fixtures/workspacebuilder/config_script_fails.yaml +++ b/tests/fixtures/workspace/builder/config_script_fails.yaml @@ -1,4 +1,4 @@ - session_name: sampleconfig + session_name: sample workspace before_script: {script_failed} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/config_script_not_exists.yaml b/tests/fixtures/workspace/builder/config_script_not_exists.yaml similarity index 66% rename from tests/fixtures/workspacebuilder/config_script_not_exists.yaml rename to tests/fixtures/workspace/builder/config_script_not_exists.yaml index ca9f57c9dd..51c76226b3 100644 --- a/tests/fixtures/workspacebuilder/config_script_not_exists.yaml +++ b/tests/fixtures/workspace/builder/config_script_not_exists.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace before_script: {script_not_exists} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/env_var_options.yaml b/tests/fixtures/workspace/builder/env_var_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/env_var_options.yaml rename to tests/fixtures/workspace/builder/env_var_options.yaml diff --git a/tests/fixtures/workspace/builder/environment_vars.yaml b/tests/fixtures/workspace/builder/environment_vars.yaml new file mode 100644 index 0000000000..1160dae67c --- /dev/null +++ b/tests/fixtures/workspace/builder/environment_vars.yaml @@ -0,0 +1,32 @@ +session_name: test env vars +start_directory: "~" +environment: + FOO: SESSION + PATH: /tmp +windows: +- window_name: no_overrides + panes: + - pane +- window_name: window_overrides + environment: + FOO: WINDOW + panes: + - pane +- window_name: pane_overrides + panes: + - environment: + FOO: PANE +- window_name: both_overrides + environment: + FOO: WINDOW + panes: + - pane + - environment: + FOO: PANE +# This test case it just needed for warnings issued in old versions of tmux. +- window_name: both_overrides_on_first_pane + environment: + FOO: WINDOW + panes: + - environment: + FOO: PANE diff --git a/tests/fixtures/workspace/builder/first_pane_start_directory.yaml b/tests/fixtures/workspace/builder/first_pane_start_directory.yaml new file mode 100644 index 0000000000..1f9ae7ed23 --- /dev/null +++ b/tests/fixtures/workspace/builder/first_pane_start_directory.yaml @@ -0,0 +1,5 @@ +session_name: sample workspace +windows: + - panes: + - start_directory: /usr + - start_directory: /etc diff --git a/tests/fixtures/workspacebuilder/focus_and_pane.yaml b/tests/fixtures/workspace/builder/focus_and_pane.yaml similarity index 60% rename from tests/fixtures/workspacebuilder/focus_and_pane.yaml rename to tests/fixtures/workspace/builder/focus_and_pane.yaml index 3a986dbf59..1dd456eb87 100644 --- a/tests/fixtures/workspacebuilder/focus_and_pane.yaml +++ b/tests/fixtures/workspace/builder/focus_and_pane.yaml @@ -1,31 +1,31 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: focused window - layout: main-horizontal focus: true panes: - shell_command: - - cd ~ + - cmd: cd ~ - shell_command: - - cd /usr + - cmd: cd /usr focus: true - shell_command: - - cd ~ - - echo "moo" - - top + - cmd: cd ~ + - cmd: echo "moo" + - cmd: top - window_name: window 2 panes: - shell_command: - - top + - cmd: top focus: true - shell_command: - - echo "hey" + - cmd: echo "hey" - shell_command: - - echo "moo" + - cmd: echo "moo" - window_name: window 3 panes: - - shell_command: cd / + - shell_command: + - cmd: cd / focus: true - pane - pane diff --git a/tests/fixtures/workspacebuilder/global_options.yaml b/tests/fixtures/workspace/builder/global_options.yaml similarity index 78% rename from tests/fixtures/workspacebuilder/global_options.yaml rename to tests/fixtures/workspace/builder/global_options.yaml index 99c6131a14..cf34d9eb60 100644 --- a/tests/fixtures/workspacebuilder/global_options.yaml +++ b/tests/fixtures/workspace/builder/global_options.yaml @@ -4,9 +4,8 @@ global_options: repeat-time: 493 status-position: 'top' windows: -- layout: main-horizontal +- window_name: moo panes: - pane - pane - pane - window_name: moo diff --git a/tests/fixtures/workspacebuilder/pane_ordering.yaml b/tests/fixtures/workspace/builder/pane_ordering.yaml similarity index 75% rename from tests/fixtures/workspacebuilder/pane_ordering.yaml rename to tests/fixtures/workspace/builder/pane_ordering.yaml index 1d6d54418c..59858582de 100644 --- a/tests/fixtures/workspacebuilder/pane_ordering.yaml +++ b/tests/fixtures/workspace/builder/pane_ordering.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: {HOME} windows: - options: @@ -7,5 +7,5 @@ windows: panes: - cd /usr/bin - cd /usr - - cd /usr/sbin + - cd /etc - cd {HOME} diff --git a/tests/fixtures/workspace/builder/plugin_awf.yaml b/tests/fixtures/workspace/builder/plugin_awf.yaml new file mode 100644 index 0000000000..5111833e14 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_awf.yaml @@ -0,0 +1,15 @@ +session_name: plugin-test-awf +plugins: +- 'tmuxp_test_plugin_awf.plugin.PluginAfterWindowFinished' +windows: +- window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/workspace/builder/plugin_awf_multiple_windows.yaml b/tests/fixtures/workspace/builder/plugin_awf_multiple_windows.yaml new file mode 100644 index 0000000000..994c2a7c4d --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_awf_multiple_windows.yaml @@ -0,0 +1,18 @@ +session_name: plugin-test-awf-mw +plugins: +- 'tmuxp_test_plugin_awf.plugin.PluginAfterWindowFinished' +windows: +- window_name: awf_mw_test + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log +- window_name: awf_mw_test_2 + layout: tiled + shell_command_before: + - cd ~/ + panes: + - echo hello \ No newline at end of file diff --git a/tests/fixtures/workspace/builder/plugin_bs.yaml b/tests/fixtures/workspace/builder/plugin_bs.yaml new file mode 100644 index 0000000000..5785afb420 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_bs.yaml @@ -0,0 +1,15 @@ +session_name: plugin-test-bs +plugins: +- 'tmuxp_test_plugin_bs.plugin.PluginBeforeScript' +windows: +- window_name: editor + layout: tiled + shell_command_before: + - cmd: cd ~/ + panes: + - shell_command: + - cmd: cd /var/log + - cmd: ls -al | grep \.log + - cmd: echo hello + - cmd: echo hello + - cmd: echo hello diff --git a/tests/fixtures/workspace/builder/plugin_bwb.yaml b/tests/fixtures/workspace/builder/plugin_bwb.yaml new file mode 100644 index 0000000000..8241f27946 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_bwb.yaml @@ -0,0 +1,15 @@ +session_name: plugin-test-bwb +plugins: +- 'tmuxp_test_plugin_bwb.plugin.PluginBeforeWorkspaceBuilder' +windows: +- window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/workspace/builder/plugin_missing_fail.yaml b/tests/fixtures/workspace/builder/plugin_missing_fail.yaml new file mode 100644 index 0000000000..4e1097debd --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_missing_fail.yaml @@ -0,0 +1,6 @@ +session_name: plugin-test-missing-fail +plugins: +- 'tmuxp_test_plugin_fail.plugin.PluginFailMissing' +windows: +- panes: + - echo "hey" \ No newline at end of file diff --git a/tests/fixtures/workspace/builder/plugin_multiple_plugins.yaml b/tests/fixtures/workspace/builder/plugin_multiple_plugins.yaml new file mode 100644 index 0000000000..113347db6e --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_multiple_plugins.yaml @@ -0,0 +1,14 @@ +session_name: plugin-test-multiple-plugins +plugins: +- 'tmuxp_test_plugin_bwb.plugin.PluginBeforeWorkspaceBuilder' +- 'tmuxp_test_plugin_owc.plugin.PluginOnWindowCreate' +- 'tmuxp_test_plugin_awf.plugin.PluginAfterWindowFinished' +windows: +- window_name: mp_test + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log \ No newline at end of file diff --git a/tests/fixtures/workspace/builder/plugin_owc.yaml b/tests/fixtures/workspace/builder/plugin_owc.yaml new file mode 100644 index 0000000000..abc81d9192 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_owc.yaml @@ -0,0 +1,15 @@ +session_name: plugin-test-owc +plugins: +- 'tmuxp_test_plugin_owc.plugin.PluginOnWindowCreate' +windows: +- window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/workspace/builder/plugin_owc_multiple_windows.yaml b/tests/fixtures/workspace/builder/plugin_owc_multiple_windows.yaml new file mode 100644 index 0000000000..c7d437e584 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_owc_multiple_windows.yaml @@ -0,0 +1,17 @@ +session_name: plugin-test-owc-mw +plugins: +- 'tmuxp_test_plugin_owc.plugin.PluginOnWindowCreate' +windows: +- window_name: owc_mw_test + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log +- window_name: owc_mw_test_2 + layout: tiled + shell_command_before: + - cd ~/ + panes: + - echo hello \ No newline at end of file diff --git a/tests/fixtures/workspace/builder/plugin_r.yaml b/tests/fixtures/workspace/builder/plugin_r.yaml new file mode 100644 index 0000000000..b220aab6b8 --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_r.yaml @@ -0,0 +1,15 @@ +session_name: plugin-test-r +plugins: +- 'tmuxp_test_plugin_r.plugin.PluginReattach' +windows: +- window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/workspace/builder/plugin_versions_fail.yaml b/tests/fixtures/workspace/builder/plugin_versions_fail.yaml new file mode 100644 index 0000000000..e4e7a4910b --- /dev/null +++ b/tests/fixtures/workspace/builder/plugin_versions_fail.yaml @@ -0,0 +1,6 @@ +session_name: plugin-test-version-fail +plugins: +- 'tmuxp_test_plugin_fail.plugin.PluginFailVersion' +windows: +- panes: + - echo "hey" \ No newline at end of file diff --git a/tests/fixtures/workspacebuilder/regression_00132_dots.yaml b/tests/fixtures/workspace/builder/regression_00132_dots.yaml similarity index 62% rename from tests/fixtures/workspacebuilder/regression_00132_dots.yaml rename to tests/fixtures/workspace/builder/regression_00132_dots.yaml index c97e9514e0..d05857c38c 100644 --- a/tests/fixtures/workspacebuilder/regression_00132_dots.yaml +++ b/tests/fixtures/workspace/builder/regression_00132_dots.yaml @@ -1,4 +1,4 @@ -# regression for https://github.com/tony/tmuxp/issues/132 +# regression for https://github.com/tmux-python/tmuxp/issues/132 session_name: dot.session-name windows: - layout: main-vertical diff --git a/tests/fixtures/workspacebuilder/session_options.yaml b/tests/fixtures/workspace/builder/session_options.yaml similarity index 78% rename from tests/fixtures/workspacebuilder/session_options.yaml rename to tests/fixtures/workspace/builder/session_options.yaml index 788c795a5c..fd2fd81a54 100644 --- a/tests/fixtures/workspacebuilder/session_options.yaml +++ b/tests/fixtures/workspace/builder/session_options.yaml @@ -4,9 +4,8 @@ options: default-shell: /bin/sh default-command: /bin/sh windows: -- layout: main-horizontal +- window_name: moo panes: - pane - pane - pane - window_name: moo diff --git a/tests/fixtures/workspacebuilder/start_directory.yaml b/tests/fixtures/workspace/builder/start_directory.yaml similarity index 86% rename from tests/fixtures/workspacebuilder/start_directory.yaml rename to tests/fixtures/workspace/builder/start_directory.yaml index a8b0ba02a1..e5ed2ec5df 100644 --- a/tests/fixtures/workspacebuilder/start_directory.yaml +++ b/tests/fixtures/workspace/builder/start_directory.yaml @@ -1,10 +1,9 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '/usr' windows: - window_name: supposed to be /usr/bin window_index: 1 start_directory: /usr/bin - layout: main-horizontal options: main-pane-height: 50 panes: @@ -15,7 +14,6 @@ windows: - window_name: support to be /dev window_index: 2 start_directory: /dev - layout: main-horizontal panes: - shell_command: - echo hello @@ -26,7 +24,6 @@ windows: - window_name: cwd containing a space window_index: 3 start_directory: {TEST_DIR} - layout: main-horizontal panes: - shell_command: - echo hello @@ -36,7 +33,6 @@ windows: - echo "moo" - window_name: testsa3 window_index: 4 - layout: main-horizontal panes: - shell_command: - echo hello @@ -46,7 +42,6 @@ windows: - echo "moo3" - window_name: cwd relative to start_directory since no rel dir entered window_index: 5 - layout: main-horizontal start_directory: ./ panes: - shell_command: diff --git a/tests/fixtures/workspacebuilder/start_directory_relative.yaml b/tests/fixtures/workspace/builder/start_directory_relative.yaml similarity index 77% rename from tests/fixtures/workspacebuilder/start_directory_relative.yaml rename to tests/fixtures/workspace/builder/start_directory_relative.yaml index 1c01108ceb..c6b0e59299 100644 --- a/tests/fixtures/workspacebuilder/start_directory_relative.yaml +++ b/tests/fixtures/workspace/builder/start_directory_relative.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: ./ windows: - window_name: supposed to be /usr/bin @@ -12,7 +12,6 @@ windows: - echo "moo" - window_name: support to be /dev start_directory: '/dev' - layout: main-horizontal panes: - shell_command: - echo hello @@ -22,7 +21,6 @@ windows: - echo "moo" - window_name: cwd containing a space start_directory: {TEST_DIR} - layout: main-horizontal panes: - shell_command: - echo hello @@ -30,8 +28,7 @@ windows: - echo "hey" - shell_command: - echo "moo" -- window_name: inherit start_directory which is rel to config file - layout: main-horizontal +- window_name: inherit start_directory which is rel to workspace file panes: - shell_command: - echo hello @@ -39,8 +36,7 @@ windows: - echo "hey" - shell_command: - echo "moo3" -- window_name: cwd relative to config file - layout: main-horizontal +- window_name: cwd relative to workspace file start_directory: ./ panes: - shell_command: diff --git a/tests/fixtures/workspace/builder/start_directory_session_path.yaml b/tests/fixtures/workspace/builder/start_directory_session_path.yaml new file mode 100644 index 0000000000..5fd35b2f4e --- /dev/null +++ b/tests/fixtures/workspace/builder/start_directory_session_path.yaml @@ -0,0 +1,6 @@ +--- +session_name: sample_start_dir_session_path +start_directory: '/usr' +windows: + - panes: + - diff --git a/tests/fixtures/workspacebuilder/suppress_history.yaml b/tests/fixtures/workspace/builder/suppress_history.yaml similarity index 85% rename from tests/fixtures/workspacebuilder/suppress_history.yaml rename to tests/fixtures/workspace/builder/suppress_history.yaml index b480f5418a..04722f9f3c 100644 --- a/tests/fixtures/workspacebuilder/suppress_history.yaml +++ b/tests/fixtures/workspace/builder/suppress_history.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' suppress_history: false windows: diff --git a/tests/fixtures/workspacebuilder/three_pane.yaml b/tests/fixtures/workspace/builder/three_pane.yaml similarity index 61% rename from tests/fixtures/workspacebuilder/three_pane.yaml rename to tests/fixtures/workspace/builder/three_pane.yaml index f883fc2b36..860fcd9371 100644 --- a/tests/fixtures/workspacebuilder/three_pane.yaml +++ b/tests/fixtures/workspace/builder/three_pane.yaml @@ -1,12 +1,12 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: test layout: main-horizontal panes: - shell_command: - - vim + - cmd: vim - shell_command: - - echo "hey" + - cmd: echo "hey" - shell_command: - - echo "moo" + - cmd: echo "moo" diff --git a/tests/fixtures/workspace/builder/three_windows.yaml b/tests/fixtures/workspace/builder/three_windows.yaml new file mode 100644 index 0000000000..b883a7da57 --- /dev/null +++ b/tests/fixtures/workspace/builder/three_windows.yaml @@ -0,0 +1,14 @@ +session_name: sample_three_windows +windows: +- window_name: first + panes: + - shell_command: + - cmd: echo 'first window' +- window_name: second + panes: + - shell_command: + - cmd: echo 'second window' +- window_name: third + panes: + - shell_command: + - cmd: echo 'third window' diff --git a/tests/fixtures/workspacebuilder/two_pane.yaml b/tests/fixtures/workspace/builder/two_pane.yaml similarity index 66% rename from tests/fixtures/workspacebuilder/two_pane.yaml rename to tests/fixtures/workspace/builder/two_pane.yaml index be083ff48b..216080feb2 100644 --- a/tests/fixtures/workspacebuilder/two_pane.yaml +++ b/tests/fixtures/workspace/builder/two_pane.yaml @@ -1,18 +1,18 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - layout: main-vertical panes: - shell_command: - - vim + - cmd: vim - shell_command: - - echo "hey" + - cmd: echo "hey" window_name: editor - panes: - shell_command: - - tail | echo 'hi' + - cmd: tail | echo 'hi' window_name: logging - window_name: test panes: - shell_command: - - htop + - cmd: htop diff --git a/tests/fixtures/workspace/builder/two_windows.yaml b/tests/fixtures/workspace/builder/two_windows.yaml new file mode 100644 index 0000000000..490382fe5f --- /dev/null +++ b/tests/fixtures/workspace/builder/two_windows.yaml @@ -0,0 +1,10 @@ +session_name: sample_two_windows +windows: +- window_name: first + panes: + - shell_command: + - cmd: echo 'first window' +- window_name: second + panes: + - shell_command: + - cmd: echo 'second window' diff --git a/tests/fixtures/workspacebuilder/window_automatic_rename.yaml b/tests/fixtures/workspace/builder/window_automatic_rename.yaml similarity index 58% rename from tests/fixtures/workspacebuilder/window_automatic_rename.yaml rename to tests/fixtures/workspace/builder/window_automatic_rename.yaml index 9b74b4fe7f..2584363930 100644 --- a/tests/fixtures/workspacebuilder/window_automatic_rename.yaml +++ b/tests/fixtures/workspace/builder/window_automatic_rename.yaml @@ -1,14 +1,16 @@ session_name: test window options start_directory: '~' windows: -- layout: main-horizontal +- window_name: renamed_window + layout: main-horizontal options: automatic-rename: on panes: - shell_command: - - sh + - cmd: man ls start_directory: '~' + focus: true - shell_command: - - echo "hey" + - cmd: echo "hey" - shell_command: - - echo "moo" + - cmd: echo "moo" diff --git a/tests/fixtures/workspacebuilder/window_index.yaml b/tests/fixtures/workspace/builder/window_index.yaml similarity index 83% rename from tests/fixtures/workspacebuilder/window_index.yaml rename to tests/fixtures/workspace/builder/window_index.yaml index 3f6c080dcd..dda2729859 100644 --- a/tests/fixtures/workspacebuilder/window_index.yaml +++ b/tests/fixtures/workspace/builder/window_index.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace windows: - window_name: zero panes: diff --git a/tests/fixtures/workspacebuilder/window_options.yaml b/tests/fixtures/workspace/builder/window_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/window_options.yaml rename to tests/fixtures/workspace/builder/window_options.yaml diff --git a/tests/fixtures/workspace/builder/window_options_after.yaml b/tests/fixtures/workspace/builder/window_options_after.yaml new file mode 100644 index 0000000000..3d60c97ffa --- /dev/null +++ b/tests/fixtures/workspace/builder/window_options_after.yaml @@ -0,0 +1,11 @@ +session_name: tmuxp test window_options_after +options: + default-shell: /bin/bash +windows: + - window_name: test + suppress_history: false + panes: + - echo 0 + - echo 1 + options_after: + synchronize-panes: on diff --git a/tests/fixtures/workspacebuilder/window_shell.yaml b/tests/fixtures/workspace/builder/window_shell.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/window_shell.yaml rename to tests/fixtures/workspace/builder/window_shell.yaml diff --git a/tests/fixtures/workspace/expand1.py b/tests/fixtures/workspace/expand1.py new file mode 100644 index 0000000000..6ad0cbb9e3 --- /dev/null +++ b/tests/fixtures/workspace/expand1.py @@ -0,0 +1,79 @@ +"""Examples of expansion of tmuxp configurations from shorthand style.""" + +from __future__ import annotations + +import pathlib +import typing as t + +before_workspace = { + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": ["vim", "top"]}, + {"shell_command": ["vim"]}, + {"shell_command": 'cowsay "hey"'}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + { + "start_directory": "/var/log", + "options": {"automatic-rename": True}, + "panes": [{"shell_command": "htop"}, "vim"], + }, + {"start_directory": "./", "panes": ["pwd"]}, + {"start_directory": "./asdf/", "panes": ["pwd"]}, + {"start_directory": "../", "panes": ["pwd"]}, + {"panes": ["top"]}, + ], +} + + +def after_workspace() -> dict[str, t.Any]: + """After expansion of shorthand style.""" + return { + "session_name": "sample workspace", + "start_directory": str(pathlib.Path().home()), + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "vim"}, {"cmd": "top"}]}, + {"shell_command": [{"cmd": "vim"}]}, + {"shell_command": [{"cmd": 'cowsay "hey"'}]}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}], + }, + { + "start_directory": "/var/log", + "options": {"automatic-rename": True}, + "panes": [ + {"shell_command": [{"cmd": "htop"}]}, + {"shell_command": [{"cmd": "vim"}]}, + ], + }, + { + "start_directory": str(pathlib.Path().home()), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + { + "start_directory": str(pathlib.Path().home() / "asdf"), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + { + "start_directory": str(pathlib.Path().home().parent.resolve()), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + {"panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } diff --git a/tests/fixtures/config/expand2-expanded.yaml b/tests/fixtures/workspace/expand2-expanded.yaml similarity index 67% rename from tests/fixtures/config/expand2-expanded.yaml rename to tests/fixtures/workspace/expand2-expanded.yaml index 0fe5bbd3d9..0809447aaa 100644 --- a/tests/fixtures/config/expand2-expanded.yaml +++ b/tests/fixtures/workspace/expand2-expanded.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: {HOME} windows: - window_name: focused window @@ -6,27 +6,27 @@ windows: focus: true panes: - shell_command: - - cd ~ + - cmd: cd ~ - shell_command: - - cd /usr + - cmd: cd /usr focus: true - shell_command: - - cd ~ - - echo "moo" - - top + - cmd: cd ~ + - cmd: echo "moo" + - cmd: top - window_name: window 2 panes: - shell_command: - - top + - cmd: top focus: true - shell_command: - - echo "hey" + - cmd: echo "hey" - shell_command: - - echo "moo" + - cmd: echo "moo" - window_name: window 3 panes: - shell_command: - - cd / + - cmd: cd / focus: true - shell_command: [] - shell_command: [] diff --git a/tests/fixtures/config/expand2-unexpanded.yaml b/tests/fixtures/workspace/expand2-unexpanded.yaml similarity index 94% rename from tests/fixtures/config/expand2-unexpanded.yaml rename to tests/fixtures/workspace/expand2-unexpanded.yaml index 6e70f42f89..6c1a49ed51 100644 --- a/tests/fixtures/config/expand2-unexpanded.yaml +++ b/tests/fixtures/workspace/expand2-unexpanded.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: focused window diff --git a/tests/fixtures/workspace/expand2.py b/tests/fixtures/workspace/expand2.py new file mode 100644 index 0000000000..7e8089bbbc --- /dev/null +++ b/tests/fixtures/workspace/expand2.py @@ -0,0 +1,19 @@ +"""YAML examples of expansion of tmuxp configurations from shorthand style.""" + +from __future__ import annotations + +import pathlib + +from tests.fixtures import utils as test_utils + + +def unexpanded_yaml() -> str: + """Return unexpanded, shorthand YAML tmuxp configuration.""" + return test_utils.read_workspace_file("workspace/expand2-unexpanded.yaml") + + +def expanded_yaml() -> str: + """Return expanded, verbose YAML tmuxp configuration.""" + return test_utils.read_workspace_file("workspace/expand2-expanded.yaml").format( + HOME=str(pathlib.Path().home()), + ) diff --git a/tests/fixtures/workspace/expand_blank.py b/tests/fixtures/workspace/expand_blank.py new file mode 100644 index 0000000000..5137605297 --- /dev/null +++ b/tests/fixtures/workspace/expand_blank.py @@ -0,0 +1,40 @@ +"""Expected expanded configuration for empty workspace panes.""" + +from __future__ import annotations + +expected = { + "session_name": "Blank pane test", + "windows": [ + { + "window_name": "Blank pane test", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + { + "window_name": "More blank panes", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + { + "window_name": "Empty string (return)", + "panes": [ + {"shell_command": [{"cmd": ""}]}, + {"shell_command": [{"cmd": ""}]}, + {"shell_command": [{"cmd": ""}]}, + ], + }, + { + "window_name": "Blank with options", + "panes": [ + {"shell_command": [], "focus": True}, + {"shell_command": [], "start_directory": "/tmp"}, + ], + }, + ], +} diff --git a/tests/fixtures/workspacefreezer/sampleconfig.yaml b/tests/fixtures/workspace/freezer/sample_workspace.yaml similarity index 58% rename from tests/fixtures/workspacefreezer/sampleconfig.yaml rename to tests/fixtures/workspace/freezer/sample_workspace.yaml index b7a6d01070..ebd3ab3f9a 100644 --- a/tests/fixtures/workspacefreezer/sampleconfig.yaml +++ b/tests/fixtures/workspace/freezer/sample_workspace.yaml @@ -1,21 +1,21 @@ -session_name: sampleconfig -start_directory: '~' +session_name: sample workspace +start_directory: "~" windows: - layout: main-vertical panes: - shell_command: - - vim - start_directory: '~' + - cmd: vim + start_directory: "~" - shell_command: - - echo "hey" - - cd ../ + - cmd: echo "hey" + - cmd: cd ../ window_name: editor - panes: - shell_command: - - pane + - cmd: pane start_directory: /usr/bin window_name: logging - window_name: test panes: - shell_command: - - top + - cmd: top diff --git a/tests/fixtures/workspace/sample_workspace.py b/tests/fixtures/workspace/sample_workspace.py new file mode 100644 index 0000000000..e00b15113a --- /dev/null +++ b/tests/fixtures/workspace/sample_workspace.py @@ -0,0 +1,28 @@ +"""Example workspace fixture for tmuxp WorkspaceBuilder.""" + +from __future__ import annotations + +sample_workspace_dict = { + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"start_directory": "~", "shell_command": ["vim"]}, + {"shell_command": ['cowsay "hey"']}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [ + { + "shell_command": ["tail -F /var/log/syslog"], + "start_directory": "/var/log", + }, + ], + }, + {"options": {"automatic_rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} diff --git a/tests/fixtures/workspace/shell_command_before.py b/tests/fixtures/workspace/shell_command_before.py new file mode 100644 index 0000000000..53f8e1587f --- /dev/null +++ b/tests/fixtures/workspace/shell_command_before.py @@ -0,0 +1,168 @@ +"""Test fixture for tmuxp to demonstrate shell_command_before.""" + +from __future__ import annotations + +import pathlib +import typing as t + +config_unexpanded = { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "shell_command_before": "source .venv/bin/activate", + "panes": [ + {"shell_command": ["vim"]}, + { + "shell_command_before": ["rbenv local 2.0.0-p0"], + "shell_command": ['cowsay "hey"'], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": "rbenv local 2.0.0-p0", + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}, {}], + }, + { + "window_name": "shufu", + "panes": [ + { + "shell_command_before": ["rbenv local 2.0.0-p0"], + "shell_command": ["htop"], + }, + ], + }, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + {"panes": ["top"]}, + ], +} + + +def config_expanded() -> dict[str, t.Any]: + """Return expanded configuration for shell_command_before example.""" + return { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": str(pathlib.Path().home()), + "shell_command_before": { + "shell_command": [{"cmd": "source .venv/bin/activate"}], + }, + "panes": [ + {"shell_command": [{"cmd": "vim"}]}, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [{"cmd": 'cowsay "hey"'}], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "window_name": "logging", + "panes": [ + {"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}, + {"shell_command": []}, + ], + }, + { + "window_name": "shufu", + "panes": [ + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [{"cmd": "htop"}], + }, + ], + }, + { + "options": {"automatic-rename": True}, + "panes": [{"shell_command": [{"cmd": "htop"}]}], + }, + {"panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } + + +def config_after() -> dict[str, t.Any]: + """Return expected configuration for shell_command_before example.""" + return { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": str(pathlib.Path().home()), + "shell_command_before": { + "shell_command": [{"cmd": "source .venv/bin/activate"}], + }, + "panes": [ + { + "shell_command": [ + {"cmd": "source .venv/bin/activate"}, + {"cmd": "vim"}, + ], + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [ + {"cmd": "source .venv/bin/activate"}, + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": 'cowsay "hey"'}, + ], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "start_directory": "/", + "window_name": "logging", + "panes": [ + { + "shell_command": [ + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": "tail -F /var/log/syslog"}, + ], + }, + {"shell_command": [{"cmd": "rbenv local 2.0.0-p0"}]}, + ], + }, + { + "start_directory": "/", + "window_name": "shufu", + "panes": [ + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [ + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": "htop"}, + ], + }, + ], + }, + { + "start_directory": "/", + "options": {"automatic-rename": True}, + "panes": [{"shell_command": [{"cmd": "htop"}]}], + }, + {"start_directory": "/", "panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } diff --git a/tests/fixtures/workspace/shell_command_before_session-expected.yaml b/tests/fixtures/workspace/shell_command_before_session-expected.yaml new file mode 100644 index 0000000000..dd55247c88 --- /dev/null +++ b/tests/fixtures/workspace/shell_command_before_session-expected.yaml @@ -0,0 +1,24 @@ +shell_command_before: + shell_command: + - cmd: 'echo "hi"' +session_name: 'test' +windows: +- window_name: editor + panes: + - shell_command: + - cmd: 'echo "hi"' + - cmd: vim + - cmd: :Ex + - shell_command: + - cmd: 'echo "hi"' + - shell_command: + - cmd: 'echo "hi"' + - cmd: cd /usr +- window_name: logging + panes: + - shell_command: + - cmd: 'echo "hi"' + - shell_command: + - cmd: 'echo "hi"' + - cmd: top + - cmd: emacs diff --git a/tests/fixtures/workspace/shell_command_before_session.py b/tests/fixtures/workspace/shell_command_before_session.py new file mode 100644 index 0000000000..adfc86f183 --- /dev/null +++ b/tests/fixtures/workspace/shell_command_before_session.py @@ -0,0 +1,10 @@ +"""Tests shell_command_before configuration.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +before = test_utils.read_workspace_file("workspace/shell_command_before_session.yaml") +expected = test_utils.read_workspace_file( + "workspace/shell_command_before_session-expected.yaml", +) diff --git a/tests/fixtures/config/shell_command_before_session.yaml b/tests/fixtures/workspace/shell_command_before_session.yaml similarity index 100% rename from tests/fixtures/config/shell_command_before_session.yaml rename to tests/fixtures/workspace/shell_command_before_session.yaml diff --git a/tests/fixtures/workspace/trickle.py b/tests/fixtures/workspace/trickle.py new file mode 100644 index 0000000000..a1012be647 --- /dev/null +++ b/tests/fixtures/workspace/trickle.py @@ -0,0 +1,51 @@ +"""Test data for tmuxp workspace fixture to demo object tree inheritance.""" + +from __future__ import annotations + +before = { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/var", + "windows": [ + { + "window_name": "editor", + "start_directory": "log", + "panes": [ + {"shell_command": [{"cmd": "vim"}]}, + {"shell_command": [{"cmd": 'cowsay "hey"'}]}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "start_directory": "~", + "panes": [ + {"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}, + {"shell_command": []}, + ], + }, + ], +} + +expected = { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/var", + "windows": [ + { + "window_name": "editor", + "start_directory": "/var/log", + "panes": [ + {"shell_command": [{"cmd": "vim"}]}, + {"shell_command": [{"cmd": 'cowsay "hey"'}]}, + ], + "layout": "main-vertical", + }, + { + "start_directory": "~", + "window_name": "logging", + "panes": [ + {"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}, + {"shell_command": []}, + ], + }, + ], +} diff --git a/tests/fixtures/workspace_builders/__init__.py b/tests/fixtures/workspace_builders/__init__.py new file mode 100644 index 0000000000..4599a8613e --- /dev/null +++ b/tests/fixtures/workspace_builders/__init__.py @@ -0,0 +1,3 @@ +"""Custom workspace builder fixtures for resolution tests.""" + +from __future__ import annotations diff --git a/tests/fixtures/workspace_builders/invalid.py b/tests/fixtures/workspace_builders/invalid.py new file mode 100644 index 0000000000..020839315f --- /dev/null +++ b/tests/fixtures/workspace_builders/invalid.py @@ -0,0 +1,19 @@ +"""Objects that are not valid workspace builders, used by resolution tests.""" + +from __future__ import annotations + +import typing as t + + +class NotABuilder: + """A class missing the required ``build`` method.""" + + +class MissingPluginsBuilder: + """A builder whose constructor omits the ``plugins`` parameter.""" + + def __init__(self, session_config: t.Any, server: t.Any) -> None: + self.session_config = session_config + + def build(self, session: t.Any = None, append: bool = False) -> None: + """No-op build for validation tests.""" diff --git a/tests/fixtures/workspace_builders/valid.py b/tests/fixtures/workspace_builders/valid.py new file mode 100644 index 0000000000..bb3fb154f9 --- /dev/null +++ b/tests/fixtures/workspace_builders/valid.py @@ -0,0 +1,9 @@ +"""A minimal valid custom workspace builder used by resolution tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """Trivial subclass that satisfies the workspace builder contract.""" diff --git a/tests/fixtures/workspacebuilder/environment_vars.yaml b/tests/fixtures/workspacebuilder/environment_vars.yaml deleted file mode 100644 index eaee25684b..0000000000 --- a/tests/fixtures/workspacebuilder/environment_vars.yaml +++ /dev/null @@ -1,10 +0,0 @@ -session_name: test env vars -start_directory: '~' -environment: - FOO: BAR - PATH: /tmp -windows: -- layout: main-horizontal - panes: - - pane - window_name: editor diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index ed859f8f70..0000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,461 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp command line interface.""" - -from __future__ import absolute_import, print_function, with_statement - -import os -import json -import libtmux -import pytest -import click -from click.testing import CliRunner - -from tmuxp import cli, config -from tmuxp.cli import is_pure_name, load_workspace, resolve_config - -from .fixtures._util import curjoin, loadfixture - - -def test_creates_config_dir_not_exists(tmpdir): - """cli.startup() creates config dir if not exists.""" - - cli.startup(str(tmpdir)) - assert os.path.exists(str(tmpdir)) - - -def test_in_dir_from_config_dir(tmpdir): - """config.in_dir() finds configs config dir.""" - - cli.startup(str(tmpdir)) - tmpdir.join("myconfig.yaml").write("") - tmpdir.join("myconfig.json").write("") - configs_found = config.in_dir(str(tmpdir)) - - assert len(configs_found) == 2 - - -def test_ignore_non_configs_from_current_dir(tmpdir): - """cli.in_dir() ignore non-config from config dir.""" - - cli.startup(str(tmpdir)) - - tmpdir.join("myconfig.psd").write("") - tmpdir.join("watmyconfig.json").write("") - configs_found = config.in_dir(str(tmpdir)) - assert len(configs_found) == 1 - - -def test_get_configs_cwd(tmpdir): - """config.in_cwd() find config in shell current working directory.""" - - confdir = tmpdir.mkdir("tmuxpconf2") - with confdir.as_cwd(): - config1 = open('.tmuxp.json', 'w+b') - config1.close() - - configs_found = config.in_cwd() - assert len(configs_found) == 1 - assert '.tmuxp.json' in configs_found - - -@pytest.mark.parametrize('path,expect', [ - ('.', False), - ('./', False), - ('', False), - ('.tmuxp.yaml', False), - ('../.tmuxp.yaml', False), - ('../', False), - ('/hello/world', False), - ('~/.tmuxp/hey', False), - ('~/work/c/tmux/', False), - ('~/work/c/tmux/.tmuxp.yaml', False), - ('myproject', True), -]) -def test_is_pure_name(path, expect): - assert is_pure_name(path) == expect - -""" - scans for .tmuxp.{yaml,yml,json} in directory, returns first result - log warning if multiple found: - - - current directory: ., ./, noarg - - relative to cwd directory: ../, ./hello/, hello/, ./hello/ - - absolute directory: /path/to/dir, /path/to/dir/, ~/ - - no path, no ext, config_dir: projectname, tmuxp - - load file directly - - - - no directory (cwd): .tmuxp.yaml - - relative to cwd: ../.tmuxp.yaml, ./hello/.tmuxp.yaml - - absolute path: /path/to/file.yaml, ~/path/to/file/.tmuxp.yaml - - Any case where file is not found should return error. -""" - - -@pytest.fixture -def homedir(tmpdir): - return tmpdir.join('home').mkdir() - - -@pytest.fixture -def configdir(homedir): - return homedir.join('.tmuxp').mkdir() - - -@pytest.fixture -def projectdir(homedir): - return homedir.join('work').join('project') - - -def test_resolve_dot(tmpdir, homedir, configdir, projectdir, monkeypatch): - monkeypatch.setenv('HOME', homedir) - projectdir.join('.tmuxp.yaml').ensure() - user_config_name = 'myconfig' - user_config = configdir.join('%s.yaml' % user_config_name).ensure() - - project_config = str(projectdir.join('.tmuxp.yaml')) - - with projectdir.as_cwd(): - expect = project_config - assert resolve_config('.') == expect - assert resolve_config('./') == expect - assert resolve_config('') == expect - assert resolve_config('../project') == expect - assert resolve_config('../project/') == expect - assert resolve_config('.tmuxp.yaml') == expect - assert resolve_config( - '../../.tmuxp/%s.yaml' % user_config_name) == str(user_config) - assert resolve_config('myconfig') == str(user_config) - assert resolve_config( - '~/.tmuxp/myconfig.yaml') == str(user_config) - - with pytest.raises(Exception): - resolve_config('.tmuxp.json') - with pytest.raises(Exception): - resolve_config('.tmuxp.ini') - with pytest.raises(Exception): - resolve_config('../') - with pytest.raises(Exception): - resolve_config('mooooooo') - - with homedir.as_cwd(): - expect = project_config - assert resolve_config('work/project') == expect - assert resolve_config('work/project/') == expect - assert resolve_config('./work/project') == expect - assert resolve_config('./work/project/') == expect - assert resolve_config( - '.tmuxp/%s.yaml' % user_config_name) == str(user_config) - assert resolve_config( - './.tmuxp/%s.yaml' % user_config_name) == str(user_config) - assert resolve_config('myconfig') == str(user_config) - assert resolve_config( - '~/.tmuxp/myconfig.yaml') == str(user_config) - - with pytest.raises(Exception): - resolve_config('') - with pytest.raises(Exception): - resolve_config('.') - with pytest.raises(Exception): - resolve_config('.tmuxp.yaml') - with pytest.raises(Exception): - resolve_config('../') - with pytest.raises(Exception): - resolve_config('mooooooo') - - with configdir.as_cwd(): - expect = project_config - assert resolve_config('../work/project') == expect - assert resolve_config('../../home/work/project') == expect - assert resolve_config('../work/project/') == expect - assert resolve_config( - '%s.yaml' % user_config_name) == str(user_config) - assert resolve_config( - './%s.yaml' % user_config_name) == str(user_config) - assert resolve_config('myconfig') == str(user_config) - assert resolve_config( - '~/.tmuxp/myconfig.yaml') == str(user_config) - - with pytest.raises(Exception): - resolve_config('') - with pytest.raises(Exception): - resolve_config('.') - with pytest.raises(Exception): - resolve_config('.tmuxp.yaml') - with pytest.raises(Exception): - resolve_config('../') - with pytest.raises(Exception): - resolve_config('mooooooo') - - with tmpdir.as_cwd(): - expect = project_config - assert resolve_config('home/work/project') == expect - assert resolve_config('./home/work/project/') == expect - assert resolve_config( - 'home/.tmuxp/%s.yaml' % user_config_name) == str(user_config) - assert resolve_config( - './home/.tmuxp/%s.yaml' % user_config_name) == str(user_config) - assert resolve_config('myconfig') == str(user_config) - assert resolve_config( - '~/.tmuxp/myconfig.yaml') == str(user_config) - - with pytest.raises(Exception): - resolve_config('') - with pytest.raises(Exception): - resolve_config('.') - with pytest.raises(Exception): - resolve_config('.tmuxp.yaml') - with pytest.raises(Exception): - resolve_config('../') - with pytest.raises(Exception): - resolve_config('mooooooo') - - -def test_resolve_config_arg(homedir, configdir, projectdir, monkeypatch): - runner = CliRunner() - - @click.command() - @click.argument( - 'config', click.Path(exists=True), nargs=-1, - callback=cli.resolve_config_argument) - def config_cmd(config): - click.echo(config) - - monkeypatch.setenv('HOME', homedir) - projectdir.join('.tmuxp.yaml').ensure() - user_config_name = 'myconfig' - user_config = configdir.join('%s.yaml' % user_config_name).ensure() - - project_config = str(projectdir.join('.tmuxp.yaml')) - - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output - - with projectdir.as_cwd(): - expect = project_config - assert expect in check_cmd('.') - assert expect in check_cmd('./') - assert expect in check_cmd('') - assert expect in check_cmd('../project') - assert expect in check_cmd('../project/') - assert expect in check_cmd('.tmuxp.yaml') - assert str(user_config) in check_cmd( - '../../.tmuxp/%s.yaml' % user_config_name) - assert user_config.purebasename in check_cmd('myconfig') - assert str(user_config) in check_cmd( - '~/.tmuxp/myconfig.yaml') - - assert 'file not found' in check_cmd('.tmuxp.json') - assert 'file not found' in check_cmd('.tmuxp.ini') - assert 'No tmuxp files found' in check_cmd('../') - assert 'config not found in config dir' in check_cmd('moo') - - -def test_load_workspace(server, monkeypatch): - # this is an implementation test. Since this testsuite may be ran within - # a tmux session by the developer themselv, delete the TMUX variable - # temporarily. - monkeypatch.delenv('TMUX', raising=False) - session_file = curjoin("workspacebuilder/two_pane.yaml") - - # open it detached - session = load_workspace( - session_file, socket_name=server.socket_name, - detached=True - ) - - assert isinstance(session, libtmux.Session) - assert session.name == 'sampleconfig' - - -def test_regression_00132_session_name_with_dots(tmpdir, server, session): - yaml_config = curjoin("workspacebuilder/regression_00132_dots.yaml") - cli_args = [yaml_config] - inputs = [] - runner = CliRunner() - result = runner.invoke( - cli.command_load, cli_args, input=''.join(inputs), - standalone_mode=False) - assert result.exception - assert isinstance(result.exception, libtmux.exc.BadSessionName) - - -@pytest.mark.parametrize("cli_args", [ - (['load', '.']), - (['load', '.tmuxp.yaml']), -]) -def test_load_zsh_autotitle_warning(cli_args, tmpdir, monkeypatch): - # create dummy tmuxp yaml so we don't get yelled at - tmpdir.join('.tmuxp.yaml').ensure() - tmpdir.join('.oh-my-zsh').ensure(dir=True) - monkeypatch.setenv('HOME', str(tmpdir)) - - with tmpdir.as_cwd(): - runner = CliRunner() - - monkeypatch.delenv('DISABLE_AUTO_TITLE', raising=False) - monkeypatch.setenv('SHELL', 'zsh') - result = runner.invoke(cli.cli, cli_args) - assert 'Please set' in result.output - - monkeypatch.setenv('DISABLE_AUTO_TITLE', 'false') - result = runner.invoke(cli.cli, cli_args) - assert 'Please set' in result.output - - monkeypatch.setenv('DISABLE_AUTO_TITLE', 'true') - result = runner.invoke(cli.cli, cli_args) - assert 'Please set' not in result.output - - monkeypatch.delenv('DISABLE_AUTO_TITLE', raising=False) - monkeypatch.setenv('SHELL', 'sh') - result = runner.invoke(cli.cli, cli_args) - assert 'Please set' not in result.output - - -@pytest.mark.parametrize("cli_args", [ - (['convert', '.']), - (['convert', '.tmuxp.yaml']), -]) -def test_convert(cli_args, tmpdir, monkeypatch): - # create dummy tmuxp yaml so we don't get yelled at - tmpdir.join('.tmuxp.yaml').write(""" -session_name: hello - """) - tmpdir.join('.oh-my-zsh').ensure(dir=True) - monkeypatch.setenv('HOME', str(tmpdir)) - - with tmpdir.as_cwd(): - runner = CliRunner() - - runner.invoke(cli.cli, cli_args, input='y\ny\n') - assert tmpdir.join('.tmuxp.json').check() - assert tmpdir.join('.tmuxp.json').open().read() == \ - json.dumps({'session_name': 'hello'}, indent=2) - - -@pytest.mark.parametrize("cli_args", [ - (['import']), -]) -def test_import(cli_args, monkeypatch): - runner = CliRunner() - - result = runner.invoke(cli.cli, cli_args) - assert 'tmuxinator' in result.output - assert 'teamocil' in result.output - - -@pytest.mark.parametrize("cli_args,inputs", [ - (['import', 'teamocil', './.teamocil/config.yaml'], - ['\n', 'y\n', './la.yaml\n', 'y\n']), - (['import', 'teamocil', './.teamocil/config.yaml'], - ['\n', 'y\n', './exists.yaml\n', './la.yaml\n', 'y\n']), - (['import', 'teamocil', 'config'], - ['\n', 'y\n', './exists.yaml\n', './la.yaml\n', 'y\n']), -]) -def test_import_teamocil(cli_args, inputs, tmpdir, monkeypatch): - teamocil_config = loadfixture('config_teamocil/test4.yaml') - teamocil_dir = tmpdir.join('.teamocil').mkdir() - teamocil_dir.join('config.yaml').write(teamocil_config) - tmpdir.join('exists.yaml').ensure() - monkeypatch.setenv('HOME', str(tmpdir)) - - with tmpdir.as_cwd(): - runner = CliRunner() - runner.invoke(cli.cli, cli_args, input=''.join(inputs)) - assert tmpdir.join('la.yaml').check() - - -@pytest.mark.parametrize("cli_args,inputs", [ - (['import', 'tmuxinator', './.tmuxinator/config.yaml'], - ['\n', 'y\n', './la.yaml\n', 'y\n']), - (['import', 'tmuxinator', './.tmuxinator/config.yaml'], - ['\n', 'y\n', './exists.yaml\n', './la.yaml\n', 'y\n']), - (['import', 'tmuxinator', 'config'], - ['\n', 'y\n', './exists.yaml\n', './la.yaml\n', 'y\n']), -]) -def test_import_tmuxinator(cli_args, inputs, tmpdir, monkeypatch): - tmuxinator_config = loadfixture('config_tmuxinator/test3.yaml') - tmuxinator_dir = tmpdir.join('.tmuxinator').mkdir() - tmuxinator_dir.join('config.yaml').write(tmuxinator_config) - tmpdir.join('exists.yaml').ensure() - monkeypatch.setenv('HOME', str(tmpdir)) - - with tmpdir.as_cwd(): - runner = CliRunner() - runner.invoke(cli.cli, cli_args, input=''.join(inputs)) - assert tmpdir.join('la.yaml').check() - - -def test_get_abs_path(tmpdir): - expect = str(tmpdir) - with tmpdir.as_cwd(): - cli.get_abs_path('../') == os.path.dirname(expect) - cli.get_abs_path('.') == expect - cli.get_abs_path('./') == expect - cli.get_abs_path(expect) == expect - - -def test_get_tmuxinator_dir(monkeypatch): - assert cli.get_tmuxinator_dir() == os.path.expanduser('~/.tmuxinator/') - - monkeypatch.setenv('HOME', '/moo') - assert cli.get_tmuxinator_dir() == '/moo/.tmuxinator/' - assert cli.get_tmuxinator_dir() == os.path.expanduser('~/.tmuxinator/') - - -def test_get_cwd(tmpdir): - assert cli.get_cwd() == os.getcwd() - - with tmpdir.as_cwd(): - assert cli.get_cwd() == str(tmpdir) - assert cli.get_cwd() == os.getcwd() - - -def test_get_teamocil_dir(monkeypatch): - assert cli.get_teamocil_dir() == os.path.expanduser('~/.teamocil/') - - monkeypatch.setenv('HOME', '/moo') - assert cli.get_teamocil_dir() == '/moo/.teamocil/' - assert cli.get_teamocil_dir() == os.path.expanduser('~/.teamocil/') - - -def test_validate_choices(): - validate = cli._validate_choices(['choice1', 'choice2']) - - assert validate('choice1') - assert validate('choice2') - - with pytest.raises(click.BadParameter): - assert validate('choice3') - - -def test_create_resolve_config_arg(tmpdir): - configdir = tmpdir.join('myconfigdir') - configdir.mkdir() - user_config_name = 'myconfig' - user_config = configdir.join('%s.yaml' % user_config_name).ensure() - - expect = str(configdir.join('myconfig.yaml')) - my_resolve_config = cli._create_resolve_config_argument(str(configdir)) - - runner = CliRunner() - - @click.command() - @click.argument( - 'config', click.Path(exists=True), nargs=-1, - callback=my_resolve_config) - def config_cmd(config): - click.echo(config) - - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output - - with configdir.as_cwd(): - assert expect in check_cmd('myconfig') - assert expect in check_cmd('myconfig.yaml') - assert expect in check_cmd('./myconfig.yaml') - assert str(user_config) in check_cmd( - str(configdir.join('myconfig.yaml'))) - - assert 'file not found' in check_cmd('.tmuxp.json') diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index c45a27706b..0000000000 --- a/tests/test_config.py +++ /dev/null @@ -1,452 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp configuration import, inlining, expanding and export.""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import os - -import kaptan -import pytest - -from tmuxp import config, exc - -from . import example_dir -from .fixtures import config as fixtures - -TMUXP_DIR = os.path.join(os.path.dirname(__file__), '.tmuxp') - - -def load_yaml(yaml): - return kaptan.Kaptan(handler='yaml').import_config(yaml).get() - - -def load_config(_file): - return kaptan.Kaptan().import_config(_file).get() - - -def test_export_json(tmpdir): - json_config_file = tmpdir.join('config.json') - - configparser = kaptan.Kaptan() - configparser.import_config(fixtures.sampleconfig.sampleconfigdict) - - json_config_data = configparser.export('json', indent=2) - - json_config_file.write(json_config_data) - - new_config = kaptan.Kaptan() - new_config_data = new_config.import_config(str(json_config_file)).get() - assert fixtures.sampleconfig.sampleconfigdict == new_config_data - - -def test_export_yaml(tmpdir): - yaml_config_file = tmpdir.join('config.yaml') - - configparser = kaptan.Kaptan() - sampleconfig = config.inline(fixtures.sampleconfig.sampleconfigdict) - configparser.import_config(sampleconfig) - - yaml_config_data = configparser.export( - 'yaml', indent=2, default_flow_style=False) - - yaml_config_file.write(yaml_config_data) - - new_config_data = load_config(str(yaml_config_file)) - assert fixtures.sampleconfig.sampleconfigdict == new_config_data - - -def test_scan_config(tmpdir): - configs = [] - - garbage_file = tmpdir.join('config.psd') - garbage_file.write('wat') - - for r, d, f in os.walk(str(tmpdir)): - for filela in ( - x for x in f if x.endswith(('.json', '.ini', 'yaml')) - ): - configs.append(str(tmpdir.join(filela))) - - files = 0 - if tmpdir.join('config.json').check(): - files += 1 - assert str(tmpdir.join('config.json')) in configs - - if tmpdir.join('config.yaml').check(): - files += 1 - assert str(tmpdir.join('config.yaml')) in configs - - if tmpdir.join('config.ini').check(): - files += 1 - assert str(tmpdir.join('config.ini')) in configs - - assert len(configs) == files - - -def test_config_expand1(): - """Expand shell commands from string to list.""" - test_config = config.expand(fixtures.expand1.before_config) - assert test_config == fixtures.expand1.after_config - - -def test_config_expand2(): - """Expand shell commands from string to list.""" - - unexpanded_dict = load_yaml(fixtures.expand2.unexpanded_yaml) - - expanded_dict = load_yaml(fixtures.expand2.expanded_yaml) - - assert config.expand(unexpanded_dict) == expanded_dict - - -"""Tests for :meth:`config.inline()`.""" - -ibefore_config = { # inline config - 'session_name': 'sampleconfig', - 'start_directory': '~', - 'windows': [ - { - 'shell_command': ['top'], - 'window_name': 'editor', - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - } - ] - }, - { - 'options': {'automatic-rename': True, }, - 'panes': [ - {'shell_command': ['htop']} - ] - } - ] -} - -iafter_config = { - 'session_name': 'sampleconfig', - 'start_directory': '~', - 'windows': [ - { - 'shell_command': 'top', - 'window_name': 'editor', - 'panes': [ - 'vim', - 'cowsay "hey"' - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - 'tail -F /var/log/syslog', - ] - }, - { - 'options': { - 'automatic-rename': True, - }, - 'panes': [ - 'htop' - ] - }, - - ] -} - - -def test_inline_config(): - """:meth:`config.inline()` shell commands list to string.""" - - test_config = config.inline(ibefore_config) - assert test_config == iafter_config - - -"""Test config inheritance for the nested 'start_command'.""" - -inheritance_config_before = { - 'session_name': 'sampleconfig', - 'start_directory': '/', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': '~', - 'panes': [ - { - 'shell_command': ['vim'], - }, - { - 'shell_command': ['cowsay "hey"'] - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - } - ] - }, - { - 'window_name': 'shufu', - 'panes': [ - { - 'shell_command': ['htop'], - } - ] - }, - { - 'options': { - 'automatic-rename': True, - }, - 'panes': [ - { - 'shell_command': ['htop'] - } - ] - } - ] -} - -inheritance_config_after = { - 'session_name': 'sampleconfig', - 'start_directory': '/', - 'windows': [ - { - 'window_name': 'editor', - 'start_directory': '~', - 'panes': [ - { - 'shell_command': ['vim'], - }, { - 'shell_command': ['cowsay "hey"'], - }, - ], - 'layout': 'main-verticle' - }, - { - 'window_name': 'logging', - 'panes': [ - { - 'shell_command': ['tail -F /var/log/syslog'], - } - ] - }, - { - 'window_name': 'shufu', - 'panes': [ - { - 'shell_command': ['htop'], - } - ] - }, - { - 'options': {'automatic-rename': True, }, - 'panes': [ - { - 'shell_command': ['htop'], - } - ] - } - ] -} - - -def test_inheritance_config(): - config = inheritance_config_before - - # TODO: Look at verifying window_start_directory - # if 'start_directory' in config: - # session_start_directory = config['start_directory'] - # else: - # session_start_directory = None - - # for windowconfitem in config['windows']: - # window_start_directory = None - # - # if 'start_directory' in windowconfitem: - # window_start_directory = windowconfitem['start_directory'] - # elif session_start_directory: - # window_start_directory = session_start_directory - # - # for paneconfitem in windowconfitem['panes']: - # if 'start_directory' in paneconfitem: - # pane_start_directory = paneconfitem['start_directory'] - # elif window_start_directory: - # paneconfitem['start_directory'] = window_start_directory - # elif session_start_directory: - # paneconfitem['start_directory'] = session_start_directory - - assert config == inheritance_config_after - - -def test_shell_command_before(): - """Config inheritance for the nested 'start_command'.""" - test_config = fixtures.shell_command_before.config_unexpanded - test_config = config.expand(test_config) - - assert test_config == fixtures.shell_command_before.config_expanded - - test_config = config.trickle(test_config) - assert test_config == fixtures.shell_command_before.config_after - - -def test_in_session_scope(): - sconfig = load_yaml(fixtures.shell_command_before_session.before) - - config.validate_schema(sconfig) - - assert config.expand(sconfig) == sconfig - assert config.expand(config.trickle(sconfig)) == \ - load_yaml(fixtures.shell_command_before_session.expected) - - -def test_trickle_relative_start_directory(): - test_config = config.trickle(fixtures.trickle.before) - assert test_config == fixtures.trickle.expected - - -def test_expands_blank_panes(): - """Expand blank config into full form. - - Handle ``NoneType`` and 'blank':: - - # nothing, None, 'blank' - 'panes': [ - None, - 'blank' - ] - - # should be blank - 'panes': [ - 'shell_command': [] - ] - - Blank strings:: - - panes: [ - '' - ] - - # should output to: - panes: - 'shell_command': [''] - - """ - - yaml_config_file = os.path.join(example_dir, 'blank-panes.yaml') - test_config = load_config(yaml_config_file) - assert config.expand(test_config) == fixtures.expand_blank.expected - - -def test_no_session_name(): - yaml_config = """ - - window_name: editor - panes: - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - window_name: logging - automatic-rename: true - panes: - - shell_command: - - htop - """ - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.matches(r'requires "session_name"') - - -def test_no_windows(): - yaml_config = """ - session_name: test session - """ - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.match(r'list of "windows"') - - -def test_no_window_name(): - yaml_config = """ - session_name: test session - windows: - - window_name: editor - panes: - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - automatic-rename: true - panes: - - shell_command: - - htop - """ - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.matches('missing "window_name"') - - -def test_replaces_env_variables(monkeypatch): - env_key = "TESTHEY92" - env_val = "HEYO1" - yaml_config = """ - start_directory: {TEST_VAR}/test - shell_command_before: {TEST_VAR}/test2 - before_script: {TEST_VAR}/test3 - session_name: hi - {TEST_VAR} - options: - default-command: {TEST_VAR}/lol - global_options: - default-shell: {TEST_VAR}/moo - windows: - - window_name: editor - panes: - - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - window_name: logging @ {TEST_VAR} - automatic-rename: true - panes: - - shell_command: - - htop - """.format( - TEST_VAR="${%s}" % env_key - ) - - sconfig = load_yaml(yaml_config) - - monkeypatch.setenv(env_key, env_val) - sconfig = config.expand(sconfig) - assert "%s/test" % env_val == sconfig['start_directory'] - assert "%s/test2" % env_val in sconfig['shell_command_before'] - assert "%s/test3" % env_val == sconfig['before_script'] - assert "hi - %s" % env_val == sconfig['session_name'] - assert "%s/moo" % env_val == sconfig['global_options']['default-shell'] - assert "%s/lol" % env_val == sconfig['options']['default-command'] - assert "logging @ %s" % env_val == sconfig['windows'][1]['window_name'] diff --git a/tests/test_config_teamocil.py b/tests/test_config_teamocil.py deleted file mode 100644 index 0cfddd5546..0000000000 --- a/tests/test_config_teamocil.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp teamocil configuration.""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import os - -import kaptan -import pytest - -from tmuxp import config - -from .fixtures import config_teamocil as fixtures - -TMUXP_DIR = os.path.join(os.path.dirname(__file__), '.tmuxp') - - -@pytest.mark.parametrize("teamocil_yaml,teamocil_dict,tmuxp_dict", [ - (fixtures.test1.teamocil_yaml, fixtures.test1.teamocil_conf, - fixtures.test1.expected), - (fixtures.test2.teamocil_yaml, fixtures.test2.teamocil_dict, - fixtures.test2.expected), - (fixtures.test3.teamocil_yaml, fixtures.test3.teamocil_dict, - fixtures.test3.expected), - (fixtures.test4.teamocil_yaml, fixtures.test4.teamocil_dict, - fixtures.test4.expected), -]) -def test_config_to_dict(teamocil_yaml, teamocil_dict, tmuxp_dict): - configparser = kaptan.Kaptan(handler='yaml') - test_config = configparser.import_config(teamocil_yaml) - yaml_to_dict = test_config.get() - assert yaml_to_dict == teamocil_dict - - assert config.import_teamocil(teamocil_dict) == tmuxp_dict - - config.validate_schema( - config.import_teamocil( - teamocil_dict - ) - ) - - -@pytest.fixture(scope='module') -def multisession_config(): - """Return loaded multisession teamocil config as a dictionary. - - Also prevents re-running assertion the loads the yaml, since ordering of - deep list items like panes will be inconsistent.""" - teamocil_yaml = fixtures.layouts.teamocil_yaml - configparser = kaptan.Kaptan(handler='yaml') - test_config = configparser.import_config(teamocil_yaml) - teamocil_dict = fixtures.layouts.teamocil_dict - - assert test_config.get() == teamocil_dict - return teamocil_dict - - -@pytest.mark.parametrize("session_name,expected", [ - ('two-windows', fixtures.layouts.two_windows), - ('two-windows-with-filters', fixtures.layouts.two_windows_with_filters), - ('two-windows-with-custom-command-options', - fixtures.layouts.two_windows_with_custom_command_options), - ('three-windows-within-a-session', - fixtures.layouts.three_windows_within_a_session), -]) -def test_multisession_config(session_name, expected, multisession_config): - # teamocil can fit multiple sessions in a config - assert config.import_teamocil( - multisession_config[session_name] - ) == expected - - config.validate_schema( - config.import_teamocil( - multisession_config[session_name] - ) - ) diff --git a/tests/test_config_tmuxinator.py b/tests/test_config_tmuxinator.py deleted file mode 100644 index 640946cd89..0000000000 --- a/tests/test_config_tmuxinator.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp tmuxinator configuration.""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import os - -import kaptan -import pytest - -from tmuxp import config - -from .fixtures import config_tmuxinator as fixtures - -TMUXP_DIR = os.path.join(os.path.dirname(__file__), '.tmuxp') - - -@pytest.mark.parametrize("tmuxinator_yaml,tmuxinator_dict,tmuxp_dict", [ - (fixtures.test1.tmuxinator_yaml, fixtures.test1.tmuxinator_dict, - fixtures.test1.expected), - (fixtures.test2.tmuxinator_yaml, fixtures.test2.tmuxinator_dict, - fixtures.test2.expected), # older vers use `tabs` instead of `windows` - (fixtures.test3.tmuxinator_yaml, fixtures.test3.tmuxinator_dict, - fixtures.test3.expected), # Test importing -]) -def test_config_to_dict(tmuxinator_yaml, tmuxinator_dict, tmuxp_dict): - configparser = kaptan.Kaptan(handler='yaml') - test_config = configparser.import_config(tmuxinator_yaml) - yaml_to_dict = test_config.get() - assert yaml_to_dict == tmuxinator_dict - - assert config.import_tmuxinator(tmuxinator_dict) == tmuxp_dict - - config.validate_schema( - config.import_tmuxinator( - tmuxinator_dict - ) - ) diff --git a/tests/test_docs_tmux_layout.py b/tests/test_docs_tmux_layout.py new file mode 100644 index 0000000000..b5f1b6b0d2 --- /dev/null +++ b/tests/test_docs_tmux_layout.py @@ -0,0 +1,224 @@ +"""Tests for the tmux-layout directive (``docs/_ext/tmux_layout.py``).""" + +from __future__ import annotations + +import importlib.util +import types +import typing as t +from pathlib import Path + +import pytest + +_EXT_PATH = Path(__file__).resolve().parent.parent / "docs" / "_ext" / "tmux_layout.py" +_spec = importlib.util.spec_from_file_location("tmux_layout", _EXT_PATH) +assert _spec is not None +assert _spec.loader is not None +tl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(tl) + + +class ArrangeCase(t.NamedTuple): + """A named layout with a pane count and the geometry it should produce.""" + + test_id: str + layout: str + n: int + expect_count: int + + +_ARRANGE_CASES: list[ArrangeCase] = [ + ArrangeCase("even-vertical-3", "even-vertical", 3, 3), + ArrangeCase("even-horizontal-2", "even-horizontal", 2, 2), + ArrangeCase("main-vertical-3", "main-vertical", 3, 3), + ArrangeCase("main-horizontal-3", "main-horizontal", 3, 3), + ArrangeCase("tiled-4", "tiled", 4, 4), + ArrangeCase("tiled-3-spans-last-row", "tiled", 3, 3), + ArrangeCase("single-pane", "even-vertical", 1, 1), +] + + +@pytest.mark.parametrize( + "case", + _ARRANGE_CASES, + ids=[c.test_id for c in _ARRANGE_CASES], +) +def test_arrange_fills_screen(case: ArrangeCase) -> None: + """Each arrangement returns the right panes and tiles the screen exactly.""" + width, height = 80, 24 + rects = tl.arrange(case.layout, case.n, width, height) + assert len(rects) == case.expect_count + # Non-overlapping panes that fill the screen sum to its area. + area = sum(r.w * r.h for r in rects) + assert area == pytest.approx(width * height) + # Every pane stays within the screen. + for r in rects: + assert r.x >= -1e-6 + assert r.y >= -1e-6 + assert r.x + r.w <= width + 1e-6 + assert r.y + r.h <= height + 1e-6 + + +def test_arrange_unknown_layout_raises() -> None: + """An unknown layout name is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="unknown layout"): + tl.arrange("spiral", 2, 80, 24) + + +class HighlightCase(t.NamedTuple): + """A shell command and the Pygments class its first token should carry.""" + + test_id: str + command: str + expect_class: str + + +_HIGHLIGHT_CASES: list[HighlightCase] = [ + HighlightCase("single-quoted-string", "echo 'hi there'", 'class="s1"'), + HighlightCase("comment", "# a note", 'class="c1"'), +] + + +@pytest.mark.parametrize( + "case", + _HIGHLIGHT_CASES, + ids=[c.test_id for c in _HIGHLIGHT_CASES], +) +def test_highlight_emits_pygments_classes(case: HighlightCase) -> None: + """``_highlight`` wraps shell tokens in Pygments-classed tspans.""" + out = tl._highlight(case.command) + assert case.expect_class in out + assert " None: + """A command builtin (echo/pwd) is left in the default colour, no nb class.""" + assert 'class="nb"' not in tl._highlight("echo hello") + assert 'class="nb"' not in tl._highlight("pwd") + + +def test_highlight_escapes_markup() -> None: + """Angle brackets in a command are HTML-escaped, not emitted as tags.""" + out = tl._highlight("echo ") + assert "" not in out + assert "<a>" in out + + +class SplitCase(t.NamedTuple): + """Directive content lines and the per-pane command lists they yield.""" + + test_id: str + content: list[str] + expect: list[list[str]] + + +_SPLIT_CASES: list[SplitCase] = [ + SplitCase("two-panes", ["echo a", "---", "echo b"], [["echo a"], ["echo b"]]), + SplitCase( + "multiline-pane", + ["echo a", "echo b", "---", "echo c"], + [["echo a", "echo b"], ["echo c"]], + ), + SplitCase( + "blank-lines-dropped", ["echo a", "", "---", "echo b"], [["echo a"], ["echo b"]] + ), +] + + +@pytest.mark.parametrize( + "case", + _SPLIT_CASES, + ids=[c.test_id for c in _SPLIT_CASES], +) +def test_split_panes(case: SplitCase) -> None: + """``_split_panes`` splits on ``---`` and keeps each pane's commands.""" + assert tl._split_panes(case.content) == case.expect + + +class SizeCase(t.NamedTuple): + """A ``:size:`` string and the (cols, rows) it parses to.""" + + test_id: str + size: str + expect: tuple[int, int] + + +_SIZE_CASES: list[SizeCase] = [ + SizeCase("plain", "64x18", (64, 18)), + SizeCase("uppercase-x", "80X24", (80, 24)), +] + + +@pytest.mark.parametrize( + "case", + _SIZE_CASES, + ids=[c.test_id for c in _SIZE_CASES], +) +def test_parse_size(case: SizeCase) -> None: + """``_parse_size`` reads a ``WxH`` screen size.""" + assert tl._parse_size(case.size) == case.expect + + +def test_parse_size_invalid_raises() -> None: + """A malformed size is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="invalid size"): + tl._parse_size("not-a-size") + + +def test_render_layout_structure() -> None: + """The rendered SVG has one window, one rect per pane, and highlighting.""" + svg = tl.render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + assert svg.startswith("") + + +def test_render_layout_clips_compact_pane_text_without_rescaling() -> None: + """Compact panes clip long text instead of shrinking the whole command.""" + svg = tl.render_layout( + [ + ["cd /var/log", "ls -al | grep \\.log"], + ["echo hello"], + ["echo hello"], + ["echo hello"], + ], + "tiled", + (30, 12), + ) + assert "ls<" in svg + assert ">grep<" in svg + assert "\\.log" in svg + + +def test_setup_registers_components() -> None: + """``setup`` registers the node, directive, css, and is parallel-safe.""" + recorded: dict[str, list[t.Any]] = {"nodes": [], "directives": [], "css": []} + + def add_node(node: object, **kwargs: object) -> None: + recorded["nodes"].append(node) + + def add_directive(name: str, cls: object) -> None: + recorded["directives"].append((name, cls)) + + def add_css_file(name: str, **kwargs: object) -> None: + recorded["css"].append(name) + + app = types.SimpleNamespace( + add_node=add_node, + add_directive=add_directive, + add_css_file=add_css_file, + ) + meta = tl.setup(t.cast("t.Any", app)) + + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True + assert tl.tmux_layout in recorded["nodes"] + assert ("tmux-layout", tl.TmuxLayoutDirective) in recorded["directives"] + assert "css/gp-tmux-layout.css" in recorded["css"] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000000..6d7fda7fd1 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,111 @@ +"""Tests for tmuxp plugin API.""" + +from __future__ import annotations + +import logging + +import pytest + +from tmuxp.exc import TmuxpPluginException + +from .fixtures.pluginsystem.partials.all_pass import AllVersionPassPlugin +from .fixtures.pluginsystem.partials.libtmux_version_fail import ( + LibtmuxVersionFailIncompatiblePlugin, + LibtmuxVersionFailMaxPlugin, + LibtmuxVersionFailMinPlugin, +) +from .fixtures.pluginsystem.partials.tmux_version_fail import ( + TmuxVersionFailIncompatiblePlugin, + TmuxVersionFailMaxPlugin, + TmuxVersionFailMinPlugin, +) +from .fixtures.pluginsystem.partials.tmuxp_version_fail import ( + TmuxpVersionFailIncompatiblePlugin, + TmuxpVersionFailMaxPlugin, + TmuxpVersionFailMinPlugin, +) + + +@pytest.fixture(autouse=True) +def autopatch_sitedir(monkeypatch_plugin_test_packages: None) -> None: + """Fixture automatically used that patches sitedir.""" + + +def test_all_pass() -> None: + """Plugin for tmuxp that loads successfully.""" + AllVersionPassPlugin() + + +def test_tmux_version_fail_min() -> None: + """Plugin raises if tmux version is below minimum constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxVersionFailMinPlugin() + assert "tmux-min-version-fail" in str(exc_info.value) + + +def test_tmux_version_fail_max() -> None: + """Plugin raises if tmux version is above maximum constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxVersionFailMaxPlugin() + assert "tmux-max-version-fail" in str(exc_info.value) + + +def test_tmux_version_fail_incompatible() -> None: + """Plugin raises if tmuxp version is incompatible.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxVersionFailIncompatiblePlugin() + assert "tmux-incompatible-version-fail" in str(exc_info.value) + + +def test_tmuxp_version_fail_min() -> None: + """Plugin raises if tmuxp version is below minimum constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxpVersionFailMinPlugin() + assert "tmuxp-min-version-fail" in str(exc_info.value) + + +def test_tmuxp_version_fail_max() -> None: + """Plugin raises if tmuxp version is above max constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxpVersionFailMaxPlugin() + assert "tmuxp-max-version-fail" in str(exc_info.value) + + +def test_tmuxp_version_fail_incompatible() -> None: + """Plugin raises if libtmux version is below minimum constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + TmuxpVersionFailIncompatiblePlugin() + assert "tmuxp-incompatible-version-fail" in str(exc_info.value) + + +def test_libtmux_version_fail_min() -> None: + """Plugin raises if libtmux version is below minimum constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + LibtmuxVersionFailMinPlugin() + assert "libtmux-min-version-fail" in str(exc_info.value) + + +def test_libtmux_version_fail_max() -> None: + """Plugin raises if libtmux version is above max constraint.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + LibtmuxVersionFailMaxPlugin() + assert "libtmux-max-version-fail" in str(exc_info.value) + + +def test_libtmux_version_fail_incompatible() -> None: + """Plugin raises if libtmux version is incompatible.""" + with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: + LibtmuxVersionFailIncompatiblePlugin() + assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000000..d544439d60 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,19 @@ +"""Tests for tmuxp shell module.""" + +from __future__ import annotations + +from tmuxp import shell + + +def test_detect_best_shell() -> None: + """detect_best_shell() returns a a string of the best shell.""" + result = shell.detect_best_shell() + assert isinstance(result, str) + + +def test_shell_detect() -> None: + """Tests shell detection functions.""" + assert isinstance(shell.has_bpython(), bool) + assert isinstance(shell.has_ipython(), bool) + assert isinstance(shell.has_ptpython(), bool) + assert isinstance(shell.has_ptipython(), bool) diff --git a/tests/test_util.py b/tests/test_util.py index 407615890c..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,22 +1,30 @@ -# -*- coding: utf-8 -*- -"""Tests for utility functions in tmux.""" +"""Tests for tmuxp's utility functions.""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) +from __future__ import annotations +import logging import os +import pathlib +import sys +import typing as t import pytest from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script -from . import fixtures_dir +from .constants import FIXTURE_PATH +if t.TYPE_CHECKING: + import pathlib -def test_raise_BeforeLoadScriptNotExists_if_not_exists(): - script_file = os.path.join(fixtures_dir, 'script_noexists.sh') + from libtmux.server import Server + + +def test_run_before_script_raise_BeforeLoadScriptNotExists_if_not_exists() -> None: + """run_before_script() raises BeforeLoadScriptNotExists if script not found.""" + script_file = FIXTURE_PATH / "script_noexists.sh" with pytest.raises(BeforeLoadScriptNotExists): run_before_script(script_file) @@ -25,32 +33,204 @@ def test_raise_BeforeLoadScriptNotExists_if_not_exists(): run_before_script(script_file) -def test_raise_BeforeLoadScriptError_if_retcode(): - script_file = os.path.join(fixtures_dir, 'script_failed.sh') +def test_run_before_script_raise_BeforeLoadScriptError_if_retcode() -> None: + """run_before_script() raises BeforeLoadScriptNotExists if script fails.""" + script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(BeforeLoadScriptError): run_before_script(script_file) -def test_return_stdout_if_ok(capsys): - script_file = os.path.join(fixtures_dir, 'script_complete.sh') +@pytest.fixture +def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: + """Fixture of an example script that prints "Hello, world!".""" + script = tmp_path / "test_script.sh" + script.write_text( + """#!/bin/sh +echo "Hello, World!" +exit 0 +""", + ) + script.chmod(0o755) + return script + + +class TTYTestFixture(t.NamedTuple): + """Test fixture for isatty behavior verification.""" + + test_id: str + isatty_value: bool + expected_output: str + + +TTY_TEST_FIXTURES: list[TTYTestFixture] = [ + TTYTestFixture( + test_id="tty_enabled_shows_output", + isatty_value=True, + expected_output="Hello, World!", + ), + TTYTestFixture( + test_id="tty_disabled_suppresses_output", + isatty_value=False, + expected_output="", + ), +] + + +@pytest.mark.parametrize( + list(TTYTestFixture._fields), + TTY_TEST_FIXTURES, + ids=[test.test_id for test in TTY_TEST_FIXTURES], +) +def test_run_before_script_isatty( + temp_script: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + isatty_value: bool, + expected_output: str, +) -> None: + """Verify behavior of ``isatty()``, which we mock in `run_before_script()`.""" + # Mock sys.stdout.isatty() to return the desired value. + monkeypatch.setattr(sys.stdout, "isatty", lambda: isatty_value) + + # Run the script. + returncode = run_before_script(temp_script) + + # Assert that the script ran successfully. + assert returncode == 0 + + out, _err = capsys.readouterr() + + # In TTY mode, we expect the output; in non-TTY mode, we expect it to be suppressed. + assert expected_output in out + + +def test_return_stdout_if_ok( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_before_script() returns stdout if script succeeds.""" + # Simulate sys.stdout.isatty() + sys.stderr.isatty() + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True) + + script_file = FIXTURE_PATH / "script_complete.sh" run_before_script(script_file) - out, err = capsys.readouterr() - assert 'hello' in out + out, _err = capsys.readouterr() + assert "hello" in out -def test_beforeload_returncode(): - script_file = os.path.join(fixtures_dir, 'script_failed.sh') +def test_beforeload_returncode() -> None: + """run_before_script() returns returncode if script fails.""" + script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(exc.BeforeLoadScriptError) as excinfo: run_before_script(script_file) - assert excinfo.match(r'113') + assert excinfo.match(r"113") -def test_beforeload_returns_stderr_messages(): - script_file = os.path.join(fixtures_dir, 'script_failed.sh') +def test_beforeload_returns_stderr_messages() -> None: + """run_before_script() returns stderr messages if script fails.""" + script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(exc.BeforeLoadScriptError) as excinfo: run_before_script(script_file) - assert excinfo.match(r'failed with returncode') + assert excinfo.match(r"failed with returncode") + + +def test_get_session_should_default_to_local_attached_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_session() should launch current terminal's tmux session, if inside one.""" + server.new_session(session_name="myfirstsession") + second_session = server.new_session(session_name="mysecondsession") + + # Assign an active pane to the session + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + assert first_pane_on_second_session_id is not None + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + assert get_session(server) == second_session + + +def test_get_session_should_return_first_session_if_no_active_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_session() should return first session if no active session.""" + # Clear outer tmux environment to ensure no active pane interferes + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + + first_session = server.new_session(session_name="myfirstsession") + server.new_session(session_name="mysecondsession") + + assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + 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 diff --git a/tests/test_workspacebuilder.py b/tests/test_workspacebuilder.py deleted file mode 100644 index e3f344f441..0000000000 --- a/tests/test_workspacebuilder.py +++ /dev/null @@ -1,669 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp workspacebuilder.""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import os -import time - -import kaptan -import pytest - -from . import fixtures_dir -from libtmux import Window -from libtmux.test import temp_session -from tmuxp import config, exc -from tmuxp._compat import text_type -from tmuxp.workspacebuilder import WorkspaceBuilder - -from . import example_dir -from .fixtures._util import loadfixture - - -def test_split_windows(session): - yaml_config = loadfixture("workspacebuilder/two_pane.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - - -def test_split_windows_three_pane(session): - yaml_config = loadfixture("workspacebuilder/three_pane.yaml") - - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(s._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - w.set_window_option('main-pane-height', 50) - w.select_layout(wconf['layout']) - - -def test_focus_pane_index(session): - yaml_config = loadfixture('workspacebuilder/focus_and_pane.yaml') - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - builder.build(session=session) - - assert session.attached_window.name == \ - 'focused window' - - pane_base_index = int( - session.attached_window.show_window_option( - 'pane-base-index', g=True - ) - ) - - if not pane_base_index: - pane_base_index = 0 - else: - pane_base_index = int(pane_base_index) - - # get the pane index for each pane - pane_base_indexes = [] - for pane in session.attached_window.panes: - pane_base_indexes.append(int(pane.index)) - - pane_indexes_should_be = [pane_base_index + x for x in range(0, 3)] - assert pane_indexes_should_be == pane_base_indexes - - w = session.attached_window - - assert w.name != 'man' - - pane_path = '/usr' - for _ in range(20): - p = w.attached_pane - p.server._update_panes() - if p.current_path == pane_path: - break - time.sleep(.4) - - assert p.current_path == pane_path - - proc = session.cmd('show-option', '-gv', 'base-index') - base_index = int(proc.stdout[0]) - session.server._update_windows() - - window3 = session.find_where({'window_index': str(base_index + 2)}) - assert isinstance(window3, Window) - - p = None - pane_path = '/' - for _ in range(10): - p = window3.attached_pane - p.server._update_panes() - if p.current_path == pane_path: - break - time.sleep(.4) - - assert p.current_path == pane_path - - -@pytest.mark.flaky(reruns=5) -def test_suppress_history(session): - yaml_config = loadfixture("workspacebuilder/suppress_history.yaml") - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - inHistoryPane = session.find_where( - {'window_name': 'inHistory'}).attached_pane - isMissingPane = session.find_where( - {'window_name': 'isMissing'}).attached_pane - - def assertHistory(cmd, hist): - return 'inHistory' in cmd and cmd.endswith(hist) - - def assertIsMissing(cmd, hist): - return 'isMissing' in cmd and not cmd.endswith(hist) - - for p, assertCase in [ - (inHistoryPane, assertHistory,), (isMissingPane, assertIsMissing,) - ]: - correct = False - p.window.select_window() - p.select_pane() - - # Print the last-in-history command in the pane - session.cmd('send-keys', ' fc -ln -1') - session.cmd('send-keys', 'Enter') - - for _ in range(10): - time.sleep(0.1) - - # Get the contents of the pane - session.cmd('capture-pane') - captured_pane = session.cmd('show-buffer') - session.cmd('delete-buffer') - - # Parse the sent and last-in-history commands - sent_cmd = captured_pane.stdout[0].strip() - history_cmd = captured_pane.stdout[-2].strip() - - if assertCase(sent_cmd, history_cmd): - correct = True - break - assert correct, "Unknown sent command: [%s]" % sent_cmd - - -def test_session_options(session): - yaml_config = loadfixture("workspacebuilder/session_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert "/bin/sh" in s.show_option('default-shell') - assert "/bin/sh" in s.show_option('default-command') - - -def test_global_options(session): - yaml_config = loadfixture("workspacebuilder/global_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert "top" in s.show_option('status-position', g=True) - assert 493 == s.show_option('repeat-time', g=True) - - -def test_global_session_env_options(session, monkeypatch): - visual_silence = 'on' - monkeypatch.setenv('VISUAL_SILENCE', visual_silence) - repeat_time = 738 - monkeypatch.setenv('REPEAT_TIME', repeat_time) - main_pane_height = 8 - monkeypatch.setenv('MAIN_PANE_HEIGHT', main_pane_height) - - yaml_config = loadfixture("workspacebuilder/env_var_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert visual_silence in s.show_option('visual-silence', g=True) - assert repeat_time == s.show_option('repeat-time') - assert main_pane_height == \ - s.attached_window.show_window_option('main-pane-height') - - -def test_window_options(session): - yaml_config = loadfixture("workspacebuilder/window_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - assert w.show_window_option('main-pane-height') == 5 - - assert len(s._windows) == window_count - window_count += 1 - w.select_layout(wconf['layout']) - - -def test_window_shell(session): - yaml_config = loadfixture("workspacebuilder/window_shell.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - for w, wconf in builder.iter_create_windows(s): - if 'window_shell' in wconf: - assert wconf['window_shell'] == text_type('top') - for _ in range(10): - session.server._update_windows() - if w['window_name'] != 'top': - break - time.sleep(.2) - - assert w.name != text_type('top') - - -def test_environment_variables(session): - yaml_config = loadfixture("workspacebuilder/environment_vars.yaml") - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session) - - assert session.show_environment('FOO') == 'BAR' - assert session.show_environment('PATH') == '/tmp' - - -def test_automatic_rename_option(session): - """With option automatic-rename: on.""" - yaml_config = loadfixture("workspacebuilder/window_automatic_rename.yaml") - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - p = p - assert len(s._windows), window_count - assert isinstance(w, Window) - assert w.show_window_option('automatic-rename') == 'on' - - assert len(s._windows) == window_count - - window_count += 1 - w.select_layout(wconf['layout']) - - assert s.name != 'tmuxp' - w = s.windows[0] - - for _ in range(10): - session.server._update_windows() - if w.name != 'sh': - break - time.sleep(.2) - - assert w.name != 'sh' - - pane_base_index = w.show_window_option('pane-base-index', g=True) - w.select_pane(pane_base_index) - - for _ in range(10): - session.server._update_windows() - if w.name == 'sh': - break - time.sleep(.3) - - assert w.name == text_type('sh') - - w.select_pane('-D') - for _ in range(10): - session.server._update_windows() - if w['window_name'] != 'sh': - break - time.sleep(.2) - - assert w.name != text_type('sh') - - -def test_blank_pane_count(session): - """:todo: Verify blank panes of various types build into workspaces.""" - yaml_config_file = os.path.join(example_dir, 'blank-panes.yaml') - test_config = kaptan.Kaptan().import_config( - yaml_config_file).get() - test_config = config.expand(test_config) - builder = WorkspaceBuilder(sconf=test_config) - builder.build(session=session) - - assert session == builder.session - - window1 = session.find_where({'window_name': 'Blank pane test'}) - assert len(window1._panes) == 3 - - window2 = session.find_where({'window_name': 'More blank panes'}) - assert len(window2._panes) == 3 - - window3 = session.find_where( - {'window_name': 'Empty string (return)'} - ) - assert len(window3._panes) == 3 - - window4 = session.find_where({'window_name': 'Blank with options'}) - assert len(window4._panes) == 2 - - -def test_start_directory(session, tmpdir): - yaml_config = loadfixture("workspacebuilder/start_directory.yaml") - test_dir = str(tmpdir.mkdir('foo bar')) - test_config = yaml_config.format( - TMP_DIR=str(tmpdir), - TEST_DIR=test_dir - ) - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(test_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert session == builder.session - dirs = [ - '/usr/bin', '/dev', test_dir, - '/usr', - '/usr' - ] - - for path, window in zip(dirs, session.windows): - for p in window.panes: - for _ in range(60): - p.server._update_panes() - pane_path = p.current_path - if pane_path is None: - pass - elif ( - path in pane_path or - pane_path == path - ): - result = ( - path == pane_path or - path in pane_path - ) - break - time.sleep(.2) - - # handle case with OS X adding /private/ to /tmp/ paths - assert result - - -def test_start_directory_relative(session, tmpdir): - """Same as above test, but with relative start directory, mimicing - loading it from a location of project file. Like:: - - $ tmuxp load ~/workspace/myproject/.tmuxp.yaml - - instead of:: - - $ cd ~/workspace/myproject/.tmuxp.yaml - $ tmuxp load . - - """ - yaml_config = \ - loadfixture("workspacebuilder/start_directory_relative.yaml") - - test_dir = str(tmpdir.mkdir('foo bar')) - config_dir = str(tmpdir.mkdir('testRelConfigDir')) - test_config = yaml_config.format( - TEST_DIR=test_dir, - ) - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(test_config).get() - # the second argument of os.getcwd() mimics the behavior - # the CLI loader will do, but it passes in the config file's location. - sconfig = config.expand(sconfig, config_dir) - - sconfig = config.trickle(sconfig) - - assert os.path.exists(config_dir) - assert os.path.exists(test_dir) - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert session == builder.session - - dirs = [ - '/usr/bin', - '/dev', - test_dir, - config_dir, - config_dir, - ] - - for path, window in zip(dirs, session.windows): - for p in window.panes: - for _ in range(60): - p.server._update_panes() - # Handle case where directories resolve to /private/ in OSX - pane_path = p.current_path - if pane_path is None: - pass - elif ( - path in pane_path or - pane_path == path - ): - result = ( - path == pane_path or - path in pane_path - ) - - break - time.sleep(.2) - - assert result - - -def test_pane_order(session): - """Pane ordering based on position in config and ``pane_index``. - - Regression test for https://github.com/tony/tmuxp/issues/15. - - """ - - yaml_config = loadfixture("workspacebuilder/pane_ordering.yaml").format( - HOME=os.path.realpath(os.path.expanduser('~')) - ) - - # test order of `panes` (and pane_index) above aganist pane_dirs - pane_paths = [ - '/usr/bin', - '/usr', - '/usr/sbin', - os.path.realpath(os.path.expanduser('~')) - ] - - s = session - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - p = p - assert len(s._windows) == window_count - - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - - for w in session.windows: - pane_base_index = w.show_window_option('pane-base-index', g=True) - for p_index, p in enumerate(w.list_panes(), start=pane_base_index): - assert int(p_index) == int(p.index) - - # pane-base-index start at base-index, pane_paths always start - # at 0 since python list. - pane_path = pane_paths[p_index - pane_base_index] - - for _ in range(60): - p.server._update_panes() - if p.current_path == pane_path: - break - time.sleep(.2) - - assert p.current_path, pane_path - - -def test_window_index(session): - yaml_config = loadfixture("workspacebuilder/window_index.yaml") - proc = session.cmd('show-option', '-gv', 'base-index') - base_index = int(proc.stdout[0]) - name_index_map = { - 'zero': 0 + base_index, - 'one': 1 + base_index, - 'five': 5, - } - - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - for window, _ in builder.iter_create_windows(session): - expected_index = name_index_map[window['window_name']] - assert int(window['window_index']) == expected_index - - -def test_before_load_throw_error_if_retcode_error(server): - config_script_fails = loadfixture( - "workspacebuilder/config_script_fails.yaml" - ) - sconfig = kaptan.Kaptan(handler='yaml') - yaml = config_script_fails.format( - fixtures_dir=fixtures_dir, - script_failed=os.path.join(fixtures_dir, 'script_failed.sh') - ) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as sess: - session_name = sess.name - - with pytest.raises(exc.BeforeLoadScriptError): - builder.build(session=sess) - - result = server.has_session(session_name) - assert not result, \ - "Kills session if before_script exits with errcode" - - -def test_before_load_throw_error_if_file_not_exists(server): - config_script_not_exists = loadfixture( - "workspacebuilder/config_script_not_exists.yaml" - ) - sconfig = kaptan.Kaptan(handler='yaml') - yaml = config_script_not_exists.format( - fixtures_dir=fixtures_dir, - script_not_exists=os.path.join( - fixtures_dir, 'script_not_exists.sh' - ) - ) - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as sess: - session_name = sess.name - temp_session_exists = server.has_session( - sess.name - ) - assert temp_session_exists - with pytest.raises( - (exc.BeforeLoadScriptNotExists, OSError), - ) as excinfo: - builder.build(session=sess) - excinfo.match(r'No such file or directory') - result = server.has_session(session_name) - assert not result, "Kills session if before_script doesn't exist" - - -def test_before_load_true_if_test_passes(server): - config_script_completes = loadfixture( - "workspacebuilder/config_script_completes.yaml" - ) - assert os.path.exists( - os.path.join(fixtures_dir, 'script_complete.sh')) - sconfig = kaptan.Kaptan(handler='yaml') - yaml = config_script_completes.format( - fixtures_dir=fixtures_dir, - script_complete=os.path.join(fixtures_dir, 'script_complete.sh') - ) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as session: - builder.build(session=session) - - -def test_before_load_true_if_test_passes_with_args(server): - config_script_completes = loadfixture( - "workspacebuilder/config_script_completes.yaml" - ) - - assert( - os.path.exists(os.path.join(fixtures_dir, 'script_complete.sh')) - ) - sconfig = kaptan.Kaptan(handler='yaml') - yaml = config_script_completes.format( - fixtures_dir=fixtures_dir, - script_complete=os.path.join( - fixtures_dir, 'script_complete.sh' - ) + ' -v' - ) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as session: - builder.build(session=session) diff --git a/tests/test_workspacefreezer.py b/tests/test_workspacefreezer.py deleted file mode 100644 index c40c456771..0000000000 --- a/tests/test_workspacefreezer.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for tmuxp workspacefreezer.""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import time - -import kaptan - -from tmuxp import config -from tmuxp.workspacebuilder import WorkspaceBuilder, freeze - -from .fixtures._util import loadfixture - - -def test_freeze_config(session): - yaml_config = loadfixture("workspacefreezer/sampleconfig.yaml") - sconfig = kaptan.Kaptan(handler='yaml') - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - assert session == builder.session - - time.sleep(.50) - - session = session - sconf = freeze(session) - - config.validate_schema(sconf) - - sconf = config.inline(sconf) - - kaptanconf = kaptan.Kaptan() - kaptanconf = kaptanconf.import_config(sconf) - kaptanconf.export( - 'json', - indent=2 - ) - kaptanconf.export( - 'yaml', - indent=2, - default_flow_style=False, - safe=True - ) diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py index e69de29bb2..f887a3d06e 100644 --- a/tests/tests/__init__.py +++ b/tests/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp's pytest helpers.""" diff --git a/tests/tests/test_helpers.py b/tests/tests/test_helpers.py index 288bedc88d..aa04cee8c0 100644 --- a/tests/tests/test_helpers.py +++ b/tests/tests/test_helpers.py @@ -1,21 +1,23 @@ -# -*- coding: utf-8 -*- -"""Tests for .'s helper and utility functions.""" +"""Tests for tmuxp's helper and utility functions.""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) +from __future__ import annotations + +import typing as t import pytest +from libtmux.test.random import get_test_session_name +from libtmux.test.temporary import temp_session -from libtmux.test import get_test_session_name, temp_session +if t.TYPE_CHECKING: + from libtmux.server import Server -def test_kills_session(server): +def test_temp_session_kills_session_on_exit(server: Server) -> None: + """Test temp_session() context manager kills session on exit.""" server = server session_name = get_test_session_name(server=server) - with temp_session( - server=server, session_name=session_name - ): + with temp_session(server=server, session_name=session_name): result = server.has_session(session_name) assert result @@ -23,14 +25,12 @@ def test_kills_session(server): @pytest.mark.flaky(reruns=5) -def test_if_session_killed_before(server): - """Handles situation where session already closed within context""" - +def test_temp_session_if_session_killed_before_exit(server: Server) -> None: + """Handles situation where session already closed within context.""" server = server session_name = get_test_session_name(server=server) with temp_session(server=server, session_name=session_name): - # an error or an exception within a temp_session kills the session server.kill_session(session_name) diff --git a/tests/workspace/__init__.py b/tests/workspace/__init__.py new file mode 100644 index 0000000000..ec8ccc92b4 --- /dev/null +++ b/tests/workspace/__init__.py @@ -0,0 +1 @@ +"""Workspace tests for tmuxp.""" diff --git a/tests/workspace/conftest.py b/tests/workspace/conftest.py new file mode 100644 index 0000000000..403189710a --- /dev/null +++ b/tests/workspace/conftest.py @@ -0,0 +1,27 @@ +"""Pytest configuration for tmuxp workspace tests.""" + +from __future__ import annotations + +import types + +import pytest + +from tests.fixtures.structures import WorkspaceTestData + + +@pytest.fixture +def config_fixture() -> WorkspaceTestData: + """Deferred import of tmuxp.tests.fixtures.*. + + pytest setup (conftest.py) patches os.environ["HOME"], delay execution of + os.path.expanduser until here. + """ + from tests.fixtures import workspace as test_workspace_data + + return WorkspaceTestData( + **{ + k: v + for k, v in test_workspace_data.__dict__.items() + if isinstance(v, types.ModuleType) + }, + ) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py new file mode 100644 index 0000000000..32cbdffcf4 --- /dev/null +++ b/tests/workspace/test_builder.py @@ -0,0 +1,1930 @@ +"""Test for tmuxp workspace builder.""" + +from __future__ import annotations + +import functools +import logging +import os +import pathlib +import textwrap +import time +import typing as t + +import libtmux +import pytest +from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.exc import LibTmuxException +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.test.retry import retry_until +from libtmux.test.temporary import temp_session +from libtmux.window import Window + +from tests.constants import EXAMPLE_PATH, FIXTURE_PATH +from tests.fixtures import utils as test_utils +from tmuxp import exc +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.cli.load import load_plugins +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder, classic as builder_classic +from tmuxp.workspace.builder.classic import _wait_for_pane_ready + +if t.TYPE_CHECKING: + from libtmux.server import Server + + class AssertCallbackProtocol(t.Protocol): + """Assertion callback type protocol.""" + + def __call__(self, cmd: str, hist: str) -> bool: + """Run function code for testing assertion.""" + ... + + +def test_split_windows(session: Session) -> None: + """Test workspace builder splits windows in a tmux session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/two_pane.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + + +def test_split_windows_three_pane(session: Session) -> None: + """Test workspace builder splits windows in a tmux session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/three_pane.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + w.set_option("main-pane-height", 50) + w.select_layout(wconf["layout"]) + + +def test_focus_pane_index(session: Session) -> None: + """Test focus of pane by index works correctly, including with pane-base-index.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/focus_and_pane.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert session.active_window.name == "focused window" + + pane_base_index_ = session.active_window.show_option( + "pane-base-index", + global_=True, + ) + assert isinstance(pane_base_index_, int) + pane_base_index = int(pane_base_index_) + + pane_base_index = 0 if not pane_base_index else int(pane_base_index) + + # get the pane index for each pane + pane_base_indexes = [ + int(pane.index) + for pane in session.active_window.panes + if pane is not None and pane.index is not None + ] + + pane_indexes_should_be = [pane_base_index + x for x in range(3)] + assert pane_indexes_should_be == pane_base_indexes + + w = session.active_window + + assert w.name != "man" + + pane_path = "/usr" + p = None + + def f_check() -> bool: + nonlocal p + p = w.active_pane + assert p is not None + return p.pane_current_path == pane_path + + assert retry_until(f_check) + + assert p is not None + assert p.pane_current_path == pane_path + + proc = session.cmd("show-option", "-gv", "base-index") + base_index = int(proc.stdout[0]) + + window3 = session.windows.get(window_index=str(base_index + 2)) + assert isinstance(window3, Window) + + p = None + pane_path = "/" + + def f_check_again() -> bool: + nonlocal p + p = window3.active_pane + assert p is not None + return p.pane_current_path == pane_path + + assert retry_until(f_check_again) + + assert p is not None + assert p.pane_current_path is not None + assert isinstance(p.pane_current_path, str) + assert p.pane_current_path == pane_path + + +@pytest.mark.skip( + reason=""" +Test needs to be rewritten, assertion not reliable across platforms +and CI. See https://github.com/tmux-python/tmuxp/issues/310. + """.strip(), +) +def test_suppress_history(session: Session) -> None: + """Test suppression of command history.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/suppress_history.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + inHistoryWindow = session.windows.get(window_name="inHistory") + assert inHistoryWindow is not None + isMissingWindow = session.windows.get(window_name="isMissing") + assert isMissingWindow is not None + + def assertHistory(cmd: str, hist: str) -> bool: + return "inHistory" in cmd and cmd.endswith(hist) + + def assertIsMissing(cmd: str, hist: str) -> bool: + return "isMissing" in cmd and not cmd.endswith(hist) + + for w, window_name, assertCase in [ + (inHistoryWindow, "inHistory", assertHistory), + (isMissingWindow, "isMissing", assertIsMissing), + ]: + assert w.name == window_name + w.select() + p = w.active_pane + assert p is not None + p.select() + + # Print the last-in-history command in the pane + p.cmd("send-keys", " fc -ln -1") + p.cmd("send-keys", "Enter") + + buffer_name = "test" + sent_cmd = None + + def f(p: Pane, buffer_name: str, assertCase: AssertCallbackProtocol) -> bool: + # from v0.7.4 libtmux session.cmd adds target -t self.id by default + # show-buffer doesn't accept -t, use global cmd. + + # Get the contents of the pane + p.cmd("capture-pane", "-b", buffer_name) + + captured_pane = session.server.cmd("show-buffer", "-b", buffer_name) + session.server.cmd("delete-buffer", "-b", buffer_name) + + # Parse the sent and last-in-history commands + sent_cmd = captured_pane.stdout[0].strip() + history_cmd = captured_pane.stdout[-2].strip() + + return assertCase(sent_cmd, history_cmd) + + f_ = functools.partial(f, p=p, buffer_name=buffer_name, assertCase=assertCase) + + assert retry_until(f_), f"Unknown sent command: [{sent_cmd}] in {assertCase}" + + +def test_session_options(session: Session) -> None: + """Test setting of options to session scope.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/session_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + default_shell = session.show_option("default-shell") + assert isinstance(default_shell, str) + assert "/bin/sh" in default_shell + + default_command = session.show_option("default-command") + assert isinstance(default_command, str) + assert "/bin/sh" in default_command + + +def test_global_options(session: Session) -> None: + """Test setting of global options.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/global_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + status_position = session.show_option("status-position", global_=True) + assert isinstance(status_position, str) + assert "top" in status_position + assert session.show_option("repeat-time", global_=True) == 493 + + +def test_global_session_env_options( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setting of global option variables.""" + visual_silence = "on" + monkeypatch.setenv("VISUAL_SILENCE", str(visual_silence)) + repeat_time = 738 + monkeypatch.setenv("REPEAT_TIME", str(repeat_time)) + main_pane_height = 8 + monkeypatch.setenv("MAIN_PANE_HEIGHT", str(main_pane_height)) + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/env_var_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + visual_silence_ = session.show_option("visual-silence", global_=True) + assert isinstance(visual_silence_, bool) + assert visual_silence_ is True + assert repeat_time == session.show_option("repeat-time") + assert main_pane_height == session.active_window.show_option( + "main-pane-height", + ) + + +def test_window_options( + session: Session, +) -> None: + """Test setting of window options.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_options.yaml"), + ) + workspace = loader.expand(workspace) + + workspace["windows"][0]["options"]["pane-border-format"] = " #P " + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + assert w.show_option("main-pane-height") == 5 + assert w.show_option("pane-border-format") == " #P " + + assert len(session.windows) == window_count + window_count += 1 + w.select_layout(wconf["layout"]) + + +@pytest.mark.flaky(reruns=5) +def test_window_options_after( + session: Session, +) -> None: + """Test setting window options via options_after (WorkspaceBuilder.after_window).""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_options_after.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + def assert_last_line(p: Pane, s: str) -> bool: + def f() -> bool: + pane_out = p.cmd("capture-pane", "-p", "-J").stdout + while not pane_out[-1].strip(): # delete trailing lines tmux 1.8 + pane_out.pop() + return len(pane_out) > 1 and pane_out[-2].strip() == s + + # Print output for easier debugging if assertion fails + return retry_until(f, raises=False) + + for i, pane in enumerate(session.active_window.panes): + assert assert_last_line(pane, str(i)), ( + "Initial command did not execute properly/" + str(i) + ) + pane.cmd("send-keys", "Up") # Will repeat echo + pane.enter() # in each iteration + assert assert_last_line(pane, str(i)), ( + "Repeated command did not execute properly/" + str(i) + ) + + session.cmd("send-keys", " echo moo") + session.cmd("send-keys", "Enter") + + for pane in session.active_window.panes: + assert assert_last_line( + pane, + "moo", + ), "Synchronized command did not execute properly" + + +def test_window_shell( + session: Session, +) -> None: + """Test execution of commands via tmuxp configuration.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_shell.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + for w, wconf in builder.iter_create_windows(session): + if "window_shell" in wconf: + assert wconf["window_shell"] == "top" + + def f(w: Window) -> bool: + return w.window_name != "top" + + f_ = functools.partial(f, w=w) + + retry_until(f_) + + assert w.name != "top" + + +def test_environment_variables( + session: Session, +) -> None: + """Test setting of environmental variables in tmux via workspace builder.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/environment_vars.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session) + # Give slow shells some time to settle as otherwise tests might fail. + time.sleep(0.3) + + assert session.getenv("FOO") == "SESSION" + assert session.getenv("PATH") == "/tmp" + + no_overrides_win = session.windows[0] + pane = no_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "SESSION" + + window_overrides_win = session.windows[1] + pane = window_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "WINDOW" + + pane_overrides_win = session.windows[2] + pane = pane_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "PANE" + + both_overrides_win = session.windows[3] + pane = both_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "WINDOW" + pane = both_overrides_win.panes[1] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "PANE" + + +def test_automatic_rename_option( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test workspace builder with automatic renaming enabled.""" + monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") + monkeypatch.setenv("ROWS", "36") + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_automatic_rename.yaml"), + ) + + # This should be a command guaranteed to be terminal name across systems + portable_command = workspace["windows"][0]["panes"][0]["shell_command"][0]["cmd"] + # If a command is like "man ls", get the command base name, "ls" + if " " in portable_command: + portable_command = portable_command.split(" ")[0] + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert builder.session is not None + session: Session = builder.session + w: Window = session.windows[0] + assert len(session.windows) == 1 + + assert w.name != "renamed_window" + + def check_window_name_mismatch() -> bool: + return bool(w.name != portable_command) + + assert retry_until(check_window_name_mismatch, 5, interval=0.25) + + def check_window_name_match() -> bool: + assert w.show_option("automatic-rename") is True + return w.name in { + pathlib.Path(os.getenv("SHELL", "bash")).name, + portable_command, + } + + assert retry_until( + check_window_name_match, + 4, + interval=0.05, + ), f"Window name {w.name} should be {portable_command}" + + w.select_pane("-D") + + assert retry_until(check_window_name_mismatch, 2, interval=0.25) + + +def test_blank_pane_spawn( + session: Session, +) -> None: + """Test various ways of spawning blank panes from a tmuxp configuration. + + :todo: Verify blank panes of various types build into workspaces. + """ + yaml_workspace_file = EXAMPLE_PATH / "blank-panes.yaml" + test_config = ConfigReader._from_file(yaml_workspace_file) + + test_config = loader.expand(test_config) + builder = WorkspaceBuilder(session_config=test_config, server=session.server) + builder.build(session=session) + + assert session == builder.session + + window1 = session.windows.get(window_name="Blank pane test") + assert window1 is not None + assert len(window1.panes) == 3 + + window2 = session.windows.get(window_name="More blank panes") + assert window2 is not None + assert len(window2.panes) == 3 + + window3 = session.windows.get(window_name="Empty string (return)") + assert window3 is not None + assert len(window3.panes) == 3 + + window4 = session.windows.get(window_name="Blank with options") + assert window4 is not None + assert len(window4.panes) == 2 + + +def test_start_directory(session: Session, tmp_path: pathlib.Path) -> None: + """Test workspace builder setting start_directory relative to current directory.""" + test_dir = tmp_path / "foo bar" + test_dir.mkdir() + + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/start_directory.yaml", + ) + test_config = yaml_workspace.format(TEST_DIR=test_dir) + + workspace = ConfigReader._load(fmt="yaml", content=test_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + dirs = ["/usr/bin", "/dev", str(test_dir), "/usr", "/usr"] + + for path, window in zip(dirs, session.windows, strict=False): + for p in window.panes: + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return ( + pane_path is not None and path in pane_path + ) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_start_directory_relative(session: Session, tmp_path: pathlib.Path) -> None: + """Test workspace builder setting start_directory relative to project file. + + Same as above test, but with relative start directory, mimicking + loading it from a location of project file. Like:: + + $ tmuxp load ~/workspace/myproject/.tmuxp.yaml + + instead of:: + + $ cd ~/workspace/myproject/.tmuxp.yaml + $ tmuxp load . + + """ + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/start_directory_relative.yaml", + ) + + test_dir = tmp_path / "foo bar" + test_dir.mkdir() + config_dir = tmp_path / "testRelConfigDir" + config_dir.mkdir() + + test_config = yaml_workspace.format(TEST_DIR=test_dir) + workspace = ConfigReader._load(fmt="yaml", content=test_config) + # the second argument of os.getcwd() mimics the behavior + # the CLI loader will do, but it passes in the workspace file's location. + workspace = loader.expand(workspace, config_dir) + + workspace = loader.trickle(workspace) + + assert config_dir.exists() + assert test_dir.exists() + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + + dirs = ["/usr/bin", "/dev", str(test_dir), str(config_dir), str(config_dir)] + + for path, window in zip(dirs, session.windows, strict=False): + for p in window.panes: + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return ( + pane_path is not None and path in pane_path + ) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_start_directory_sets_session_path(server: Server) -> None: + """Test start_directory setting path in session_path.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file( + "workspace/builder/start_directory_session_path.yaml", + ), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + session = builder.session + expected = f"{session.id}|/usr" + + cmd = server.cmd("list-sessions", "-F", "#{session_id}|#{session_path}") + assert expected in cmd.stdout + + +def test_pane_order(session: Session) -> None: + """Pane ordering based on position in config and ``pane_index``. + + Regression test for https://github.com/tmux-python/tmuxp/issues/15. + """ + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/pane_ordering.yaml", + ).format(HOME=str(pathlib.Path().home().resolve())) + + # test order of `panes` (and pane_index) above against pane_dirs + pane_paths = [ + "/usr/bin", + "/usr", + "/etc", + str(pathlib.Path().home().resolve()), + ] + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + + for w, wconf in builder.iter_create_windows(session): + for _ in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + assert len(session.windows) == window_count + + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + + for w in session.windows: + pane_base_index = w.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + pane_base_index = int(pane_base_index) + for p_index, p in enumerate(w.panes, start=pane_base_index): + assert p.index is not None + assert int(p_index) == int(p.index) + + # pane-base-index start at base-index, pane_paths always start + # at 0 since python list. + pane_path = pane_paths[p_index - pane_base_index] + + def f(pane_path: str, p: Pane) -> bool: + p.refresh() + return p.pane_current_path == pane_path + + f_ = functools.partial(f, pane_path=pane_path, p=p) + + assert retry_until(f_) + + +def test_window_index( + session: Session, +) -> None: + """Test window_index respected by workspace builder.""" + proc = session.cmd("show-option", "-gv", "base-index") + base_index = int(proc.stdout[0]) + name_index_map = {"zero": 0 + base_index, "one": 1 + base_index, "five": 5} + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_index.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + for window, _ in builder.iter_create_windows(session): + expected_index = name_index_map[window.window_name] + assert int(window.window_index) == expected_index + + +def test_before_script_throw_error_if_retcode_error( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test tmuxp configuration before_script when command fails.""" + config_script_fails = test_utils.read_workspace_file( + "workspace/builder/config_script_fails.yaml", + ) + yaml_workspace = config_script_fails.format( + script_failed=FIXTURE_PATH / "script_failed.sh", + ) + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as sess: + session_name = sess.name + assert session_name is not None + + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): + builder.build(session=sess) + + result = server.has_session(session_name) + assert not result, "Kills session if before_script exits with errcode" + + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + + +def test_before_script_throw_error_if_file_not_exists( + server: Server, +) -> None: + """Test tmuxp configuration before_script when script does not exist.""" + config_script_not_exists = test_utils.read_workspace_file( + "workspace/builder/config_script_not_exists.yaml", + ) + yaml_workspace = config_script_not_exists.format( + script_not_exists=FIXTURE_PATH / "script_not_exists.sh", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + session_name = session.name + + assert session_name is not None + temp_session_exists = server.has_session(session_name) + assert temp_session_exists + with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)) as excinfo: + builder.build(session=session) + excinfo.match(r"No such file or directory") + result = server.has_session(session_name) + assert not result, "Kills session if before_script doesn't exist" + + +def test_before_script_true_if_test_passes( + server: Server, +) -> None: + """Test tmuxp configuration before_script when command succeeds.""" + config_script_completes = test_utils.read_workspace_file( + "workspace/builder/config_script_completes.yaml", + ) + script_complete_sh = FIXTURE_PATH / "script_complete.sh" + assert script_complete_sh.exists() + + yaml_workspace = config_script_completes.format(script_complete=script_complete_sh) + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + builder.build(session=session) + + +def test_before_script_true_if_test_passes_with_args( + server: Server, +) -> None: + """Test tmuxp configuration before_script when command passes w/ args.""" + config_script_completes = test_utils.read_workspace_file( + "workspace/builder/config_script_completes.yaml", + ) + script_complete_sh = FIXTURE_PATH / "script_complete.sh" + assert script_complete_sh.exists() + + yaml_workspace = config_script_completes.format(script_complete=script_complete_sh) + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + builder.build(session=session) + + +def test_plugin_system_before_workspace_builder( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hook before workspace builder starts.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_bwb.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#S'") + assert proc.stdout[0] == "'plugin_test_bwb'" + + +def test_plugin_system_on_window_create( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work on window creation.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_owc.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'plugin_test_owc'" + + +def test_plugin_system_after_window_finished( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work after windows created.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_awf.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'plugin_test_awf'" + + +def test_plugin_system_on_window_create_multiple_windows( + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work on windows creation.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_owc_multiple_windows.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("list-windows", "-F", "'#W'") + assert "'plugin_test_owc_mw'" in proc.stdout + assert "'plugin_test_owc_mw_2'" in proc.stdout + + +def test_plugin_system_after_window_finished_multiple_windows( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work after windows created.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_awf_multiple_windows.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("list-windows", "-F", "'#W'") + assert "'plugin_test_awf_mw'" in proc.stdout + assert "'plugin_test_awf_mw_2'" in proc.stdout + + +def test_plugin_system_multiple_plugins( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp plugin system works with multiple plugins.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_multiple_plugins.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + # Drop through to the before_script plugin hook + proc = session.cmd("display-message", "-p", "'#S'") + assert proc.stdout[0] == "'plugin_test_bwb'" + + # Drop through to the after_window_finished. This won't succeed + # unless on_window_create succeeds because of how the test plugin + # override methods are currently written + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'mp_test_awf'" + + +def test_load_configs_same_session( + server: Server, +) -> None: + """Test tmuxp configuration can be loaded into same session.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0].windows) == 3 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert len(server.sessions) == 2 + assert len(server.sessions[1].windows) == 2 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build(server.sessions[1], True) + + assert len(server.sessions) == 2 + assert len(server.sessions[1].windows) == 4 + + +def test_load_configs_separate_sessions( + server: Server, +) -> None: + """Test workspace builder can load configuration in separate session.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0].windows) == 3 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 2 + assert len(server.sessions[0].windows) == 3 + assert len(server.sessions[1].windows) == 2 + + +def test_find_current_active_pane( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests workspace builder can find the current active pane (and session).""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 2 + + # Assign an active pane to the session + second_session = server.sessions[1] + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + + assert first_pane_on_second_session_id is not None + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + assert builder.find_current_attached_session() == second_session + + +class WorkspaceEnterFixture(t.NamedTuple): + """Test fixture for workspace enter behavior verification.""" + + test_id: str + yaml: str + output: str + should_see: bool + + +WORKSPACE_ENTER_FIXTURES: list[WorkspaceEnterFixture] = [ + WorkspaceEnterFixture( + test_id="pane_enter_false_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( + test_id="pane_enter_false_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( + test_id="pane_enter_default_shortform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_enter_default_longform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: + - echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_command_enter_false_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( # NOQA: PT014 RUF100 + test_id="pane_command_enter_false_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( # NOQA: PT014 RUF100 + test_id="pane_command_enter_default_shortform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_command_enter_default_longform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: + - cmd: echo "other command" + - cmd: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceEnterFixture._fields), + WORKSPACE_ENTER_FIXTURES, + ids=[test.test_id for test in WORKSPACE_ENTER_FIXTURES], +) +def test_load_workspace_enter( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + output: str, + should_see: bool, +) -> None: + """Test workspace enters commands to panes in tmuxp configuration.""" + yaml_workspace = tmp_path / "simple.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + session = builder.session + assert isinstance(session, Session) + pane = session.active_pane + assert isinstance(pane, Pane) + + def fn() -> bool: + captured_pane = "\n".join(pane.capture_pane()) + + if should_see: + return output in captured_pane + return output not in captured_pane + + assert retry_until( + fn, + 1, + ), f"Should{' ' if should_see else 'not '} output in captured pane" + + +class WorkspaceSleepFixture(t.NamedTuple): + """Test fixture for workspace sleep behavior verification.""" + + test_id: str + yaml: str + sleep: float + output: str + + +WORKSPACE_SLEEP_FIXTURES: list[WorkspaceSleepFixture] = [ + WorkspaceSleepFixture( + test_id="command_level_sleep_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 5))___" + sleep_before: .15 + - cmd: echo "___$((1 + 3))___" + sleep_before: .35 + """, + ), + sleep=0.5, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="command_level_pane_sleep_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 5))___" + sleep_before: 1 + - cmd: echo "___$((1 + 3))___" + sleep_before: .25 + """, + ), + sleep=1.25, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="pane_sleep_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + sleep_before: .5 + """, + ), + sleep=0.5, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="pane_sleep_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + sleep_before: 1 + """, + ), + sleep=1, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="shell_before_before_command_level", + yaml=textwrap.dedent( + """ +session_name: Should not execute +shell_command_before: + - cmd: echo "sleeping before" + sleep_before: .5 +windows: +- panes: + - echo "___$((1 + 3))___" + """, + ), + sleep=0.5, + output="___4___", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceSleepFixture._fields), + WORKSPACE_SLEEP_FIXTURES, + ids=[test.test_id for test in WORKSPACE_SLEEP_FIXTURES], +) +@pytest.mark.flaky(reruns=3) +def test_load_workspace_sleep( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + sleep: float, + output: str, +) -> None: + """Test sleep commands in tmuxp configuration.""" + yaml_workspace = tmp_path / "simple.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=server) + + start_time = time.process_time() + + builder.build() + time.sleep(0.5) + session = builder.session + assert isinstance(builder.session, Session) + assert session is not None + pane = session.active_pane + assert isinstance(pane, Pane) + + assert pane is not None + + assert not isinstance(pane.capture_pane, str) + assert callable(pane.capture_pane) + + while (time.process_time() - start_time) * 1000 < sleep: + captured_pane = "\n".join(pane.capture_pane()) + + assert output not in captured_pane + time.sleep(0.1) + + captured_pane = "\n".join(pane.capture_pane()) + assert output in captured_pane + + +def test_first_pane_start_directory(session: Session, tmp_path: pathlib.Path) -> None: + """Test the first pane start_directory sticks.""" + yaml_workspace = test_utils.get_workspace_file( + "workspace/builder/first_pane_start_directory.yaml", + ) + + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + dirs = ["/usr", "/etc"] + + assert session.windows + window = session.windows[0] + for path, p in zip(dirs, window.panes, strict=False): + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return (pane_path is not None and path in pane_path) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_layout_main_horizontal(session: Session) -> None: + """Test that tmux's main-horizontal layout is used when specified.""" + yaml_workspace = test_utils.get_workspace_file("workspace/builder/three_pane.yaml") + workspace = ConfigReader._from_file(path=yaml_workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session.windows + window = session.windows[0] + + assert len(window.panes) == 3 + main_horizontal_pane, *panes = window.panes + + def height(p: Pane) -> int: + return int(p.pane_height) if p.pane_height is not None else 0 + + def width(p: Pane) -> int: + return int(p.pane_width) if p.pane_width is not None else 0 + + main_horizontal_pane_height = height(main_horizontal_pane) + pane_heights = [height(pane) for pane in panes] + # TODO: When libtmux has new pane formatters added, use that to detect top / bottom + assert all( + main_horizontal_pane_height != pane_height for pane_height in pane_heights + ), "The top row should not be the same size as the bottom row (even though it can)" + assert all(pane_heights[0] == pane_height for pane_height in pane_heights), ( + "The bottom row should be uniform height" + ) + assert width(main_horizontal_pane) > width(panes[0]) + + def is_almost_equal(x: int, y: int) -> bool: + return abs(x - y) <= 1 + + assert is_almost_equal(height(panes[0]), height(panes[1])) + assert is_almost_equal(width(panes[0]), width(panes[1])) + + +class DefaultSizeNamespaceFixture(t.NamedTuple): + """Pytest fixture default-size option in tmuxp workspace builder.""" + + # pytest parametrize needs a unique id for each fixture + test_id: str + + # test params + TMUXP_DEFAULT_SIZE: str | None + raises: bool + confoverrides: dict[str, t.Any] + + +DEFAULT_SIZE_FIXTURES = [ + DefaultSizeNamespaceFixture( + test_id="default-behavior", + TMUXP_DEFAULT_SIZE=None, + raises=False, + confoverrides={}, + ), + DefaultSizeNamespaceFixture( + test_id="v1.13.1 default-size-breaks", + TMUXP_DEFAULT_SIZE=None, + raises=True, + confoverrides={"options": {"default-size": "80x24"}}, + ), + DefaultSizeNamespaceFixture( + test_id="v1.13.1-option-workaround", + TMUXP_DEFAULT_SIZE=None, + raises=False, + confoverrides={"options": {"default-size": "800x600"}}, + ), +] + + +@pytest.mark.parametrize( + DefaultSizeNamespaceFixture._fields, + DEFAULT_SIZE_FIXTURES, + ids=[f.test_id for f in DEFAULT_SIZE_FIXTURES], +) +def test_issue_800_default_size_many_windows( + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + TMUXP_DEFAULT_SIZE: str | None, + raises: bool, + confoverrides: dict[str, t.Any], +) -> None: + """Recreate default-size issue. + + v1.13.1 added a default-size, but this can break building workspaces with + a lot of panes. + + See also: https://github.com/tmux-python/tmuxp/issues/800 + + 2024-04-07: This test isn't being used as of this date, as default-size is totally + unused in builder.py. + """ + monkeypatch.setenv("ROWS", "36") + + yaml_workspace = test_utils.get_workspace_file( + "regressions/issue_800_default_size_many_windows.yaml", + ) + + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + if isinstance(confoverrides, dict): + for k, v in confoverrides.items(): + workspace[k] = v + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + if raises: + with pytest.raises( + ( + LibTmuxException, + exc.TmuxpException, + exc.EmptyWorkspaceException, + ObjectDoesNotExist, + ), + ): + builder.build() + + assert builder is not None + assert builder.session is not None + assert isinstance(builder.session, Session) + assert callable(builder.session.kill) + builder.session.kill() + + # tmux 3.7 reworded this error from "no space for new pane" to + # "size or position no space for a new pane"; the optional "a " + # matches both wordings. + with pytest.raises( + libtmux.exc.LibTmuxException, + match=r"no space for (a )?new pane", + ): + builder.build() + return + + builder.build() + assert len(server.sessions) == 1 + + +def test_wait_for_pane_ready_returns_true(session: Session) -> None: + """Verify _wait_for_pane_ready detects shell prompt.""" + pane = session.active_window.active_pane + assert pane is not None + result = _wait_for_pane_ready(pane, timeout=2.0) + assert result is True + + +def test_wait_for_pane_ready_timeout(session: Session) -> None: + """Verify _wait_for_pane_ready returns False on timeout for non-shell.""" + window = session.active_window + assert window.active_pane is not None + new_pane = window.active_pane.split(shell="sleep 999") + assert new_pane is not None + result = _wait_for_pane_ready(new_pane, timeout=0.2) + assert result is False + + +class PaneReadinessFixture(t.NamedTuple): + """Test fixture for pane readiness call count verification.""" + + test_id: str + yaml: str + expected_wait_count: int + + +PANE_READINESS_FIXTURES: list[PaneReadinessFixture] = [ + PaneReadinessFixture( + test_id="waits_for_pane_with_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="waits_for_pane_without_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: [] +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="skips_pane_with_custom_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell: sleep 999 + shell_command: + - cmd: echo world +""", + ), + expected_wait_count=1, + ), + PaneReadinessFixture( + test_id="skips_all_panes_with_window_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ), + expected_wait_count=0, + ), +] + + +@pytest.mark.parametrize( + list(PaneReadinessFixture._fields), + PANE_READINESS_FIXTURES, + ids=[t.test_id for t in PANE_READINESS_FIXTURES], +) +def test_pane_readiness_call_count( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + expected_wait_count: int, +) -> None: + """Verify _wait_for_pane_ready is called only for appropriate panes.""" + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert call_count == expected_wait_count + + +def _count_readiness_waits( + *, + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + yaml: str, + shell: str | None = None, +) -> int: + """Build a workspace from ``yaml`` and count ``_wait_for_pane_ready`` calls. + + When ``shell`` is given, the resolved session shell is forced so ``auto`` + detection is deterministic regardless of the shell the suite runs under. + """ + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + if shell is not None: + monkeypatch.setattr( + builder_classic, + "resolve_session_shell", + lambda session, env=None: shell, + ) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = loader.trickle(loader.expand(ConfigReader._from_file(yaml_workspace))) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + return call_count + + +_TWO_DEFAULT_PANES = """\ +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""" + + +def _readiness_yaml(windows: str, policy: str | None = None) -> str: + """Compose a readiness workspace with an optional pane_readiness policy.""" + catalog = ( + f"workspace_builder_options:\n pane_readiness: {policy}\n" if policy else "" + ) + return f"session_name: readiness-test\n{catalog}windows:\n{windows}" + + +def test_pane_readiness_auto_waits_for_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under auto when the session shell is zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/usr/bin/zsh", + ) + assert count == 2 + + +def test_pane_readiness_auto_skips_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under auto for non-zsh shells (the intended speedup).""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/bin/bash", + ) + assert count == 0 + + +def test_pane_readiness_always_waits_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under always even for non-zsh shells.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="always"), + shell="/bin/bash", + ) + assert count == 2 + + +def test_pane_readiness_never_skips_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under never even for zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="never"), + shell="/usr/bin/zsh", + ) + assert count == 0 + + +def test_pane_readiness_custom_shell_skips_under_always( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A window_shell pane skips the wait even when policy is always.""" + windows = textwrap.dedent( + """\ +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ) + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(windows, policy="always"), + ) + assert count == 0 + + +def test_select_layout_not_called_after_yield( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify select_layout is called once per pane, not duplicated in build().""" + call_count = 0 + original_select_layout = Window.select_layout + + def counting_layout(self: Window, layout: str | None = None) -> Window: + nonlocal call_count + call_count += 1 + return original_select_layout(self, layout) + + monkeypatch.setattr(Window, "select_layout", counting_layout) + + yaml_config = textwrap.dedent( + """\ +session_name: layout-test +windows: +- layout: main-vertical + panes: + - shell_command: [] + - shell_command: [] + - shell_command: [] +""", + ) + + yaml_workspace = tmp_path / "layout.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 + assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_builder_registry.py b/tests/workspace/test_builder_registry.py new file mode 100644 index 0000000000..09dce0b143 --- /dev/null +++ b/tests/workspace/test_builder_registry.py @@ -0,0 +1,188 @@ +"""Tests for workspace builder resolution (:mod:`tmuxp.workspace.builder.registry`).""" + +from __future__ import annotations + +import sys +import textwrap +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + +VALID = "tests.fixtures.workspace_builders.valid" +INVALID = "tests.fixtures.workspace_builders.invalid" + + +def test_resolve_default_returns_classic() -> None: + """An absent workspace_builder resolves to the classic builder.""" + assert registry.resolve_builder_class({}) is ClassicWorkspaceBuilder + + +def test_resolve_entry_point_classic() -> None: + """The 'classic' entry point resolves to the classic builder.""" + resolved = registry.resolve_builder_class({"workspace_builder": "classic"}) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_dotted_object_reference() -> None: + """A ``module:attr`` reference resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}:CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_dotted_path_reference() -> None: + """A dotted ``module.attr`` path resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}.CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_compat_alias_path() -> None: + """The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` still resolves.""" + resolved = registry.resolve_builder_class( + {"workspace_builder": "tmuxp.workspace.builder:WorkspaceBuilder"}, + ) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_not_found_bare_name() -> None: + """An unknown bare name raises WorkspaceBuilderNotFound.""" + with pytest.raises(exc.WorkspaceBuilderNotFound): + registry.resolve_builder_class({"workspace_builder": "nonexistent-builder"}) + + +def test_resolve_import_error() -> None: + """An unimportable dotted path raises WorkspaceBuilderImportError.""" + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class( + {"workspace_builder": "tmuxp.does_not_exist:Thing"}, + ) + + +def test_resolve_invalid_builder() -> None: + """A class without a build method raises InvalidWorkspaceBuilder.""" + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:NotABuilder"}, + ) + + +def test_resolve_rejects_constructor_without_plugins() -> None: + """A builder whose __init__ lacks plugins is rejected before instantiation. + + The CLI always calls the builder with ``plugins=...``, so such a constructor + would otherwise raise a raw TypeError instead of a styled error. + """ + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:MissingPluginsBuilder"}, + ) + + +def test_available_builders_includes_classic() -> None: + """The classic entry point is discoverable.""" + assert "classic" in registry.available_builders() + + +def test_resolve_builder_paths_absent() -> None: + """No workspace_builder_paths resolves to an empty list.""" + assert registry.resolve_builder_paths({}, None) == [] + + +def test_resolve_builder_paths_requires_directory(tmp_path: pathlib.Path) -> None: + """A missing directory raises WorkspaceBuilderPathError.""" + with pytest.raises(exc.WorkspaceBuilderPathError): + registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path / "missing")]}, + None, + ) + + +def test_resolve_builder_paths_relative_to_workspace_file( + tmp_path: pathlib.Path, +) -> None: + """Relative entries resolve against the workspace file's directory.""" + builders_dir = tmp_path / "builders" + builders_dir.mkdir() + workspace_file = tmp_path / "workspace.yaml" + workspace_file.write_text("session_name: x\n", encoding="utf-8") + + resolved = registry.resolve_builder_paths( + {"workspace_builder_paths": ["builders"]}, + workspace_file, + ) + assert resolved == [builders_dir.resolve()] + + +def test_prepended_sys_path_restores(tmp_path: pathlib.Path) -> None: + """The sandbox restores sys.path exactly on exit.""" + before = list(sys.path) + with registry.prepended_sys_path([tmp_path]): + assert sys.path[0] == str(tmp_path) + assert sys.path == before + + +def test_trusted_path_enables_import(tmp_path: pathlib.Path) -> None: + """A builder in a trusted path imports only while the path is active.""" + sys.modules.pop("ext_builder_trusted", None) + module = tmp_path / "ext_builder_trusted.py" + module.write_text( + textwrap.dedent( + '''\ +"""External builder for trusted-path tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class ExternalBuilder(ClassicWorkspaceBuilder): + """External custom builder.""" +''', + ), + encoding="utf-8", + ) + config = {"workspace_builder": "ext_builder_trusted:ExternalBuilder"} + + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class(config) + + paths = registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path)]}, + None, + ) + with registry.prepended_sys_path(paths): + resolved = registry.resolve_builder_class(config) + assert resolved.__name__ == "ExternalBuilder" + + +def test_resolve_and_build_selected_builder(server: Server) -> None: + """A config selecting a builder builds through the resolved class.""" + config = loader.expand( + { + "session_name": "registry-build", + "workspace_builder": "classic", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "registry-build" + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py new file mode 100644 index 0000000000..fc6d5ccd5b --- /dev/null +++ b/tests/workspace/test_config.py @@ -0,0 +1,379 @@ +"""Test for tmuxp configuration import, inlining, expanding and export.""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +import pytest + +from tests.constants import EXAMPLE_PATH +from tmuxp import exc +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader, validation + +if t.TYPE_CHECKING: + from tests.fixtures.structures import WorkspaceTestData + + +def load_workspace(path: str | pathlib.Path) -> dict[str, t.Any]: + """Load tmuxp workspace configuration from file.""" + return ConfigReader._from_file( + pathlib.Path(path) if isinstance(path, str) else path, + ) + + +def test_export_json( + tmp_path: pathlib.Path, + config_fixture: WorkspaceTestData, +) -> None: + """Test exporting configuration dictionary to JSON.""" + json_workspace_file = tmp_path / "config.json" + + configparser = ConfigReader(config_fixture.sample_workspace.sample_workspace_dict) + + json_workspace_data = configparser.dump("json", indent=2) + + json_workspace_file.write_text(json_workspace_data, encoding="utf-8") + + new_workspace_data = ConfigReader._from_file(path=json_workspace_file) + assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_workspace_expand1(config_fixture: WorkspaceTestData) -> None: + """Expand shell commands from string to list.""" + test_workspace = loader.expand(config_fixture.expand1.before_workspace) + assert test_workspace == config_fixture.expand1.after_workspace() + + +def test_workspace_expand2(config_fixture: WorkspaceTestData) -> None: + """Expand shell commands from string to list.""" + unexpanded_dict = ConfigReader._load( + fmt="yaml", + content=config_fixture.expand2.unexpanded_yaml(), + ) + expanded_dict = ConfigReader._load( + fmt="yaml", + content=config_fixture.expand2.expanded_yaml(), + ) + assert loader.expand(unexpanded_dict) == expanded_dict + + +"""Test config inheritance for the nested 'start_command'.""" + +inheritance_workspace_before = { + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + +inheritance_workspace_after = { + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + + +def test_inheritance_workspace() -> None: + """TODO: Create a test to verify workspace config inheritance to object tree.""" + workspace = inheritance_workspace_before + + # TODO: Look at verifying window_start_directory + # if 'start_directory' in workspace: + # session_start_directory = workspace['start_directory'] + # else: + # session_start_directory = None + + # for windowconfitem in workspace['windows']: + # window_start_directory = None + # + # if 'start_directory' in windowconfitem: + # window_start_directory = windowconfitem['start_directory'] + # elif session_start_directory: + # window_start_directory = session_start_directory + # + # for paneconfitem in windowconfitem['panes']: + # if 'start_directory' in paneconfitem: + # pane_start_directory = paneconfitem['start_directory'] + # elif window_start_directory: + # paneconfitem['start_directory'] = window_start_directory + # elif session_start_directory: + # paneconfitem['start_directory'] = session_start_directory + + assert workspace == inheritance_workspace_after + + +def test_shell_command_before(config_fixture: WorkspaceTestData) -> None: + """Config inheritance for the nested 'start_command'.""" + test_workspace = config_fixture.shell_command_before.config_unexpanded + test_workspace = loader.expand(test_workspace) + + assert test_workspace == config_fixture.shell_command_before.config_expanded() + + test_workspace = loader.trickle(test_workspace) + assert test_workspace == config_fixture.shell_command_before.config_after() + + +def test_in_session_scope(config_fixture: WorkspaceTestData) -> None: + """Verify shell_command before_session is in session scope.""" + sconfig = ConfigReader._load( + fmt="yaml", + content=config_fixture.shell_command_before_session.before, + ) + + validation.validate_schema(sconfig) + + assert loader.expand(sconfig) == sconfig + assert loader.expand(loader.trickle(sconfig)) == ConfigReader._load( + fmt="yaml", + content=config_fixture.shell_command_before_session.expected, + ) + + +def test_trickle_relative_start_directory(config_fixture: WorkspaceTestData) -> None: + """Verify tmuxp config proliferates relative start directory to descendants.""" + test_workspace = loader.trickle(config_fixture.trickle.before) + assert test_workspace == config_fixture.trickle.expected + + +def test_trickle_window_with_no_pane_workspace() -> None: + """Verify tmuxp window config automatically infers a single pane.""" + test_yaml = """ + session_name: test_session + windows: + - window_name: test_1 + panes: + - shell_command: + - ls -l + - window_name: test_no_panes + """ + sconfig = ConfigReader._load(fmt="yaml", content=test_yaml) + validation.validate_schema(sconfig) + + assert loader.expand(loader.trickle(sconfig))["windows"][1]["panes"][0] == { + "shell_command": [], + } + + +def test_expands_blank_panes(config_fixture: WorkspaceTestData) -> None: + """Expand blank config into full form. + + Handle ``NoneType`` and 'blank':: + + # nothing, None, 'blank' + 'panes': [ + None, + 'blank' + ] + + # should be blank + 'panes': [ + 'shell_command': [] + ] + + Blank strings:: + + panes: [ + '' + ] + + # should output to: + panes: + 'shell_command': [''] + + """ + yaml_workspace_file = EXAMPLE_PATH / "blank-panes.yaml" + test_workspace = load_workspace(yaml_workspace_file) + assert loader.expand(test_workspace) == config_fixture.expand_blank.expected + + +def test_no_session_name() -> None: + """Verify exception raised when tmuxp configuration has no session name.""" + yaml_workspace = """ + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging + automatic-rename: true + panes: + - shell_command: + - htop + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match(r'requires "session_name"') + + +def test_no_windows() -> None: + """Verify exception raised when tmuxp configuration has no windows.""" + yaml_workspace = """ + session_name: test session + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match(r'list of "windows"') + + +def test_no_window_name() -> None: + """Verify exception raised when tmuxp config missing window name.""" + yaml_workspace = """ + session_name: test session + windows: + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - automatic-rename: true + panes: + - shell_command: + - htop + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match('missing "window_name"') + + +def test_replaces_env_variables(monkeypatch: pytest.MonkeyPatch) -> None: + """Test loading configuration resolves environmental variables.""" + env_key = "TESTHEY92" + env_val = "HEYO1" + yaml_workspace = """ + start_directory: {TEST_VAR}/test + shell_command_before: {TEST_VAR}/test2 + before_script: {TEST_VAR}/test3 + session_name: hi - {TEST_VAR} + options: + default-command: {TEST_VAR}/lol + global_options: + default-shell: {TEST_VAR}/moo + windows: + - window_name: editor + panes: + - shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging @ {TEST_VAR} + automatic-rename: true + panes: + - shell_command: + - htop + """.format(TEST_VAR=f"${{{env_key}}}") + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + monkeypatch.setenv(str(env_key), str(env_val)) + sconfig = loader.expand(sconfig) + assert f"{env_val}/test" == sconfig["start_directory"] + assert ( + f"{env_val}/test2" in sconfig["shell_command_before"]["shell_command"][0]["cmd"] + ) + assert f"{env_val}/test3" == sconfig["before_script"] + assert f"hi - {env_val}" == sconfig["session_name"] + assert f"{env_val}/moo" == sconfig["global_options"]["default-shell"] + assert f"{env_val}/lol" == sconfig["options"]["default-command"] + assert f"logging @ {env_val}" == sconfig["windows"][1]["window_name"] + + +def test_validate_plugins() -> None: + """Test validation of plugins loading via tmuxp configuration file.""" + yaml_workspace = """ + session_name: test session + plugins: tmuxp-plugin-one.plugin.TestPluginOne + windows: + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py new file mode 100644 index 0000000000..ab9a69dba4 --- /dev/null +++ b/tests/workspace/test_finder.py @@ -0,0 +1,568 @@ +"""Test config file searching for tmuxp.""" + +from __future__ import annotations + +import argparse +import logging +import pathlib +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp.cli.utils import tmuxp_echo +from tmuxp.workspace.finders import ( + find_local_workspace_files, + find_workspace_file, + get_workspace_dir, + get_workspace_dir_candidates, + in_cwd, + in_dir, + is_pure_name, +) + +if t.TYPE_CHECKING: + import _pytest.capture + + +def test_in_dir_from_config_dir(tmp_path: pathlib.Path) -> None: + """config.in_dir() finds configs config dir.""" + cli.startup(tmp_path) + yaml_config = tmp_path / "myconfig.yaml" + yaml_config.touch() + json_config = tmp_path / "myconfig.json" + json_config.touch() + configs_found = in_dir(tmp_path) + + assert len(configs_found) == 2 + + +def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path) -> None: + """cli.in_dir() ignore non-config from config dir.""" + cli.startup(tmp_path) + + junk_config = tmp_path / "myconfig.psd" + junk_config.touch() + conf = tmp_path / "watmyconfig.json" + conf.touch() + configs_found = in_dir(tmp_path) + assert len(configs_found) == 1 + + +def test_get_configs_cwd( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """config.in_cwd() find config in shell current working directory.""" + confdir = tmp_path / "tmuxpconf2" + confdir.mkdir() + + monkeypatch.chdir(confdir) + with pathlib.Path(".tmuxp.json").open("w+b") as config1: + config1.close() + + configs_found = in_cwd() + assert len(configs_found) == 1 + assert ".tmuxp.json" in configs_found + + +class PureNameTestFixture(t.NamedTuple): + """Test fixture for verifying pure name path validation.""" + + test_id: str + path: str + expect: bool + + +PURE_NAME_TEST_FIXTURES: list[PureNameTestFixture] = [ + PureNameTestFixture( + test_id="current_dir", + path=".", + expect=False, + ), + PureNameTestFixture( + test_id="current_dir_slash", + path="./", + expect=False, + ), + PureNameTestFixture( + test_id="empty_path", + path="", + expect=False, + ), + PureNameTestFixture( + test_id="tmuxp_yaml", + path=".tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="parent_tmuxp_yaml", + path="../.tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="parent_dir", + path="../", + expect=False, + ), + PureNameTestFixture( + test_id="absolute_path", + path="/hello/world", + expect=False, + ), + PureNameTestFixture( + test_id="home_tmuxp_path", + path="~/.tmuxp/hey", + expect=False, + ), + PureNameTestFixture( + test_id="home_work_path", + path="~/work/c/tmux/", + expect=False, + ), + PureNameTestFixture( + test_id="home_work_tmuxp_yaml", + path="~/work/c/tmux/.tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="pure_name", + path="myproject", + expect=True, + ), +] + + +@pytest.mark.parametrize( + list(PureNameTestFixture._fields), + PURE_NAME_TEST_FIXTURES, + ids=[test.test_id for test in PURE_NAME_TEST_FIXTURES], +) +def test_is_pure_name( + test_id: str, + path: str, + expect: bool, +) -> None: + """Test is_pure_name() is truthy when file, not directory or config alias.""" + assert is_pure_name(path) == expect + + +def test_tmuxp_configdir_env_var( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests get_workspace_dir() when TMUXP_CONFIGDIR is set.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + assert get_workspace_dir() == str(tmp_path) + + +def test_tmuxp_configdir_xdg_config_dir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_workspace_dir() when XDG_CONFIG_HOME is set.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + tmux_dir = tmp_path / "tmuxp" + tmux_dir.mkdir() + + assert get_workspace_dir() == str(tmux_dir) + + +@pytest.fixture +def homedir(tmp_path: pathlib.Path) -> pathlib.Path: + """Fixture to ensure and return a home directory.""" + home = tmp_path / "home" + home.mkdir() + return home + + +@pytest.fixture +def configdir(homedir: pathlib.Path) -> pathlib.Path: + """Fixture to ensure user directory for tmuxp and return it, via homedir fixture.""" + conf = homedir / ".tmuxp" + conf.mkdir() + return conf + + +@pytest.fixture +def projectdir(homedir: pathlib.Path) -> pathlib.Path: + """Fixture to ensure and return an example project dir.""" + proj = homedir / "work" / "project" + proj.mkdir(parents=True) + return proj + + +def test_resolve_dot( + tmp_path: pathlib.Path, + homedir: pathlib.Path, + configdir: pathlib.Path, + projectdir: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test find_workspace_file() resolves dots as relative / current directory.""" + monkeypatch.setenv("HOME", str(homedir)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(homedir / ".config")) + + tmuxp_conf_path = projectdir / ".tmuxp.yaml" + tmuxp_conf_path.touch() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + project_config = tmuxp_conf_path + + monkeypatch.chdir(projectdir) + + expect = str(project_config) + assert find_workspace_file(".") == expect + assert find_workspace_file("./") == expect + assert find_workspace_file("") == expect + assert find_workspace_file("../project") == expect + assert find_workspace_file("../project/") == expect + assert find_workspace_file(".tmuxp.yaml") == expect + assert find_workspace_file(f"../../.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.json") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.ini") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(homedir) + + expect = str(project_config) + assert find_workspace_file("work/project") == expect + assert find_workspace_file("work/project/") == expect + assert find_workspace_file("./work/project") == expect + assert find_workspace_file("./work/project/") == expect + assert find_workspace_file(f".tmuxp/{user_config_name}.yaml") == str(user_config) + assert find_workspace_file(f"./.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(configdir) + + expect = str(project_config) + assert find_workspace_file("../work/project") == expect + assert find_workspace_file("../../home/work/project") == expect + assert find_workspace_file("../work/project/") == expect + assert find_workspace_file(f"{user_config_name}.yaml") == str(user_config) + assert find_workspace_file(f"./{user_config_name}.yaml") == str(user_config) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(tmp_path) + + expect = str(project_config) + assert find_workspace_file("home/work/project") == expect + assert find_workspace_file("./home/work/project/") == expect + assert find_workspace_file(f"home/.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file(f"./home/.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + +def test_find_workspace_file_arg( + homedir: pathlib.Path, + configdir: pathlib.Path, + projectdir: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test find_workspace_file() via file path.""" + parser = argparse.ArgumentParser() + parser.add_argument("workspace_file", type=str) + + def config_cmd(workspace_file: str) -> None: + tmuxp_echo(find_workspace_file(workspace_file, workspace_dir=configdir)) + + monkeypatch.setenv("HOME", str(homedir)) + tmuxp_config_path = projectdir / ".tmuxp.yaml" + tmuxp_config_path.touch() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + project_config = projectdir / ".tmuxp.yaml" + + def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: + args = parser.parse_args([config_arg]) + config_cmd(workspace_file=args.workspace_file) + return capsys.readouterr() + + monkeypatch.chdir(projectdir) + expect = str(project_config) + assert expect in check_cmd(".").out + assert expect in check_cmd("./").out + assert expect in check_cmd("").out + assert expect in check_cmd("../project").out + assert expect in check_cmd("../project/").out + assert expect in check_cmd(".tmuxp.yaml").out + assert str(user_config) in check_cmd(f"../../.tmuxp/{user_config_name}.yaml").out + assert user_config.stem in check_cmd("myconfig").out + assert str(user_config) in check_cmd("~/.tmuxp/myconfig.yaml").out + + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.json").err + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.ini").err + with pytest.raises(FileNotFoundError, match="No tmuxp files found"): + assert "No tmuxp files found" in check_cmd("../").err + with pytest.raises( + FileNotFoundError, + match="workspace-file not found in workspace dir", + ): + assert "workspace-file not found in workspace dir" in check_cmd("moo").err + + +class GetWorkspaceDirCandidatesFixture(t.NamedTuple): + """Test fixture for get_workspace_dir_candidates().""" + + test_id: str + env_vars: dict[str, str] # Relative to tmp_path + dirs_to_create: list[str] # Relative to tmp_path + workspace_files: dict[str, int] # dir -> count of .yaml files to create + expected_active_suffix: str # Suffix of active dir (e.g., ".tmuxp") + expected_candidates_count: int + + +GET_WORKSPACE_DIR_CANDIDATES_FIXTURES: list[GetWorkspaceDirCandidatesFixture] = [ + GetWorkspaceDirCandidatesFixture( + test_id="default_tmuxp_only", + env_vars={}, + dirs_to_create=["home/.tmuxp"], + workspace_files={"home/.tmuxp": 3}, + expected_active_suffix=".tmuxp", + expected_candidates_count=2, # ~/.config/tmuxp (not found) + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="xdg_exists_tmuxp_not", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp"], + workspace_files={"home/.config/tmuxp": 2}, + expected_active_suffix="tmuxp", # XDG takes precedence + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="both_exist_xdg_wins", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp", "home/.tmuxp"], + workspace_files={"home/.config/tmuxp": 2, "home/.tmuxp": 5}, + expected_active_suffix="tmuxp", # XDG wins when both exist + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="custom_configdir", + env_vars={"TMUXP_CONFIGDIR": "custom/workspaces"}, + dirs_to_create=["custom/workspaces", "home/.tmuxp"], + workspace_files={"custom/workspaces": 4}, + expected_active_suffix="workspaces", + expected_candidates_count=3, # custom + ~/.config/tmuxp + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="none_exist_fallback", + env_vars={}, + dirs_to_create=[], # No dirs created + workspace_files={}, + expected_active_suffix=".tmuxp", # Falls back to ~/.tmuxp + expected_candidates_count=2, + ), +] + + +@pytest.mark.parametrize( + list(GetWorkspaceDirCandidatesFixture._fields), + GET_WORKSPACE_DIR_CANDIDATES_FIXTURES, + ids=[test.test_id for test in GET_WORKSPACE_DIR_CANDIDATES_FIXTURES], +) +def test_get_workspace_dir_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + env_vars: dict[str, str], + dirs_to_create: list[str], + workspace_files: dict[str, int], + expected_active_suffix: str, + expected_candidates_count: int, +) -> None: + """Test get_workspace_dir_candidates() returns correct candidates.""" + # Setup home directory + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + + # Clear any existing env vars that might interfere + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + # Create directories + for dir_path in dirs_to_create: + (tmp_path / dir_path).mkdir(parents=True, exist_ok=True) + + # Create workspace files + for dir_path, count in workspace_files.items(): + dir_full = tmp_path / dir_path + for i in range(count): + (dir_full / f"workspace{i}.yaml").touch() + + # Set environment variables (resolve relative paths) + for var, path in env_vars.items(): + monkeypatch.setenv(var, str(tmp_path / path)) + + # Get candidates + candidates = get_workspace_dir_candidates() + + # Verify count + assert len(candidates) == expected_candidates_count, ( + f"Expected {expected_candidates_count} candidates, got {len(candidates)}" + ) + + # Verify structure + for candidate in candidates: + assert "path" in candidate + assert "source" in candidate + assert "exists" in candidate + assert "workspace_count" in candidate + assert "active" in candidate + + # Verify exactly one is active + active_candidates = [c for c in candidates if c["active"]] + assert len(active_candidates) == 1, "Expected exactly one active candidate" + + # Verify active suffix + active = active_candidates[0] + assert active["path"].endswith(expected_active_suffix), ( + f"Expected active path to end with '{expected_active_suffix}', " + f"got '{active['path']}'" + ) + + # Verify workspace counts for existing directories + for candidate in candidates: + if candidate["exists"]: + # Find the matching dir in workspace_files by the last path component + candidate_suffix = candidate["path"].split("/")[-1] + for dir_path, expected_count in workspace_files.items(): + if dir_path.endswith(candidate_suffix): + assert candidate["workspace_count"] == expected_count, ( + f"Expected {expected_count} workspaces in {candidate['path']}, " + f"got {candidate['workspace_count']}" + ) + break # Found match, stop checking + + +def test_get_workspace_dir_candidates_uses_private_path( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that get_workspace_dir_candidates() masks home directory with ~.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + candidates = get_workspace_dir_candidates() + + # All paths should use ~ instead of full home path + for candidate in candidates: + path = candidate["path"] + assert str(home) not in path, f"Path should be masked: {path}" + assert path.startswith("~"), f"Path should start with ~: {path}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_finders_local.py b/tests/workspace/test_finders_local.py new file mode 100644 index 0000000000..5482df3abc --- /dev/null +++ b/tests/workspace/test_finders_local.py @@ -0,0 +1,285 @@ +"""Tests for local workspace file discovery with upward traversal.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp.workspace.finders import LOCAL_WORKSPACE_FILES, find_local_workspace_files + + +class LocalWorkspaceTestFixture(t.NamedTuple): + """Test fixture for local workspace file discovery.""" + + test_id: str + files: dict[str, str] # {dir_relative_to_home: filename} + start_dir: str # relative to home + expected_count: int + expected_paths: list[str] # relative to home + + +LOCAL_WORKSPACE_TEST_FIXTURES: list[LocalWorkspaceTestFixture] = [ + LocalWorkspaceTestFixture( + test_id="only_in_cwd", + files={"project": ".tmuxp.yaml"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="only_in_parent", + files={"project": ".tmuxp.yaml"}, + start_dir="project/subdir", + expected_count=1, + expected_paths=["project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="in_cwd_and_parent", + files={ + "project": ".tmuxp.yaml", + "project/subdir": ".tmuxp.yaml", + }, + start_dir="project/subdir", + expected_count=2, + expected_paths=["project/subdir/.tmuxp.yaml", "project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="multiple_ancestors", + files={ + "a": ".tmuxp.yaml", + "a/b": ".tmuxp.yaml", + "a/b/c": ".tmuxp.yaml", + }, + start_dir="a/b/c/d", + expected_count=3, + expected_paths=[ + "a/b/c/.tmuxp.yaml", + "a/b/.tmuxp.yaml", + "a/.tmuxp.yaml", + ], + ), + LocalWorkspaceTestFixture( + test_id="no_local_files", + files={}, + start_dir="project", + expected_count=0, + expected_paths=[], + ), + LocalWorkspaceTestFixture( + test_id="json_format", + files={"project": ".tmuxp.json"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.json"], + ), + LocalWorkspaceTestFixture( + test_id="yml_format", + files={"project": ".tmuxp.yml"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.yml"], + ), + LocalWorkspaceTestFixture( + test_id="stops_at_home", + files={ + "": ".tmuxp.yaml", # In home dir itself + "project": ".tmuxp.yaml", + }, + start_dir="project", + expected_count=2, # Includes home but stops there + expected_paths=["project/.tmuxp.yaml", ".tmuxp.yaml"], + ), +] + + +@pytest.mark.parametrize( + LocalWorkspaceTestFixture._fields, + LOCAL_WORKSPACE_TEST_FIXTURES, + ids=[test.test_id for test in LOCAL_WORKSPACE_TEST_FIXTURES], +) +def test_find_local_workspace_files( + test_id: str, + files: dict[str, str], + start_dir: str, + expected_count: int, + expected_paths: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test local workspace file discovery with upward traversal.""" + home = tmp_path / "home" + home.mkdir() + + # Create directory structure and files + for rel_dir, filename in files.items(): + dir_path = home / rel_dir if rel_dir else home + dir_path.mkdir(parents=True, exist_ok=True) + (dir_path / filename).write_text("session_name: test\n") + + # Ensure start directory exists + start_path = home / start_dir + start_path.mkdir(parents=True, exist_ok=True) + + # Mock home directory + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Run the function + result = find_local_workspace_files(start_path, stop_at_home=True) + + assert len(result) == expected_count + + # Verify paths match expected (relative to home) + result_relative = [str(p.relative_to(home)) for p in result] + assert result_relative == expected_paths + + +class TestFindLocalWorkspaceEdgeCases: + """Edge case tests for local workspace discovery.""" + + def test_at_home_directory( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test behavior when starting at home directory.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (home / ".tmuxp.yaml").write_text("session_name: home\n") + + result = find_local_workspace_files(home, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == home / ".tmuxp.yaml" + + def test_at_filesystem_root( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test traversal stops at filesystem root.""" + # This test verifies no infinite loop at root + result = find_local_workspace_files(pathlib.Path("/"), stop_at_home=False) + # Should complete without error; result depends on system state + assert isinstance(result, list) + + def test_yaml_precedence_over_json( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test .yaml is preferred when multiple formats exist.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create both formats + (project / ".tmuxp.yaml").write_text("session_name: yaml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yaml" + + def test_yml_precedence_over_json( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test .yml is preferred when .yaml not present but .json exists.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create yml and json (no yaml) + (project / ".tmuxp.yml").write_text("session_name: yml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yml" + + def test_stop_at_home_false_continues_past_home( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test stop_at_home=False continues traversal past home.""" + # Create structure: /grandparent/home/project + grandparent = tmp_path / "grandparent" + home = grandparent / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Put config in grandparent (above home) + (grandparent / ".tmuxp.yaml").write_text("session_name: grandparent\n") + (project / ".tmuxp.yaml").write_text("session_name: project\n") + + # With stop_at_home=True, should only find project config + result_stop = find_local_workspace_files(project, stop_at_home=True) + assert len(result_stop) == 1 + assert "project" in str(result_stop[0]) + + # With stop_at_home=False, should find both + result_continue = find_local_workspace_files(project, stop_at_home=False) + assert len(result_continue) >= 2 + + def test_default_start_dir_uses_cwd( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that None start_dir uses current working directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.chdir(project) + + (project / ".tmuxp.yaml").write_text("session_name: cwd\n") + + result = find_local_workspace_files(None, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == project / ".tmuxp.yaml" + + def test_symlinked_directory( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test behavior with symlinked directories.""" + home = tmp_path / "home" + real_project = home / "real_project" + real_project.mkdir(parents=True) + symlink_project = home / "symlink_project" + symlink_project.symlink_to(real_project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (real_project / ".tmuxp.yaml").write_text("session_name: test\n") + + result = find_local_workspace_files(symlink_project, stop_at_home=True) + + assert len(result) == 1 + + +class TestLocalWorkspaceFilesConstant: + """Tests for LOCAL_WORKSPACE_FILES constant.""" + + def test_constant_order(self) -> None: + """Verify LOCAL_WORKSPACE_FILES has correct order (yaml, yml, json).""" + assert LOCAL_WORKSPACE_FILES == [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + + def test_constant_is_list(self) -> None: + """Verify LOCAL_WORKSPACE_FILES is a list.""" + assert isinstance(LOCAL_WORKSPACE_FILES, list) + assert len(LOCAL_WORKSPACE_FILES) == 3 diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py new file mode 100644 index 0000000000..d42386ecef --- /dev/null +++ b/tests/workspace/test_freezer.py @@ -0,0 +1,136 @@ +"""Tests tmux session freezing functionality for tmuxp.""" + +from __future__ import annotations + +import logging +import time +import typing + +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import freezer, validation +from tmuxp.workspace.builder import WorkspaceBuilder + +if typing.TYPE_CHECKING: + import pathlib + + from libtmux.session import Session + + from tests.fixtures.structures import WorkspaceTestData + + +def test_freeze_config(session: Session) -> None: + """Test freezing a tmux session.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + assert session == builder.session + + time.sleep(0.50) + + session = session + new_config = freezer.freeze(session) + + validation.validate_schema(new_config) + + # These should dump without an error + ConfigReader._dump(fmt="json", content=new_config) + ConfigReader._dump(fmt="yaml", content=new_config) + + # Inline configs should also dump without an error + compact_config = freezer.inline(new_config) + + ConfigReader._dump(fmt="json", content=compact_config) + ConfigReader._dump(fmt="yaml", content=compact_config) + + +"""Tests for :meth:`freezer.inline()`.""" + +ibefore_workspace = { # inline config + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "shell_command": ["top"], + "window_name": "editor", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + +iafter_workspace = { + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "shell_command": "top", + "window_name": "editor", + "panes": ["vim", 'cowsay "hey"'], + "layout": "main-vertical", + }, + {"window_name": "logging", "panes": ["tail -F /var/log/syslog"]}, + {"options": {"automatic-rename": True}, "panes": ["htop"]}, + ], +} + + +def test_inline_workspace() -> None: + """:meth:`freezer.inline()` shell commands list to string.""" + test_workspace = freezer.inline(ibefore_workspace) + assert test_workspace == iafter_workspace + + +def test_export_yaml( + tmp_path: pathlib.Path, + config_fixture: WorkspaceTestData, +) -> None: + """Test exporting a frozen tmux session to YAML.""" + yaml_workspace_file = tmp_path / "config.yaml" + + sample_workspace = freezer.inline( + config_fixture.sample_workspace.sample_workspace_dict, + ) + configparser = ConfigReader(sample_workspace) + + yaml_workspace_data = configparser.dump("yaml", indent=2, default_flow_style=False) + + yaml_workspace_file.write_text(yaml_workspace_data, encoding="utf-8") + + new_workspace_data = ConfigReader._from_file(yaml_workspace_file) + assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py new file mode 100644 index 0000000000..0ea457e7c6 --- /dev/null +++ b/tests/workspace/test_import_teamocil.py @@ -0,0 +1,159 @@ +"""Test for tmuxp teamocil configuration.""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest + +from tests.fixtures import import_teamocil as fixtures +from tmuxp._internal import config_reader +from tmuxp.workspace import importers, validation + + +class TeamocilConfigTestFixture(t.NamedTuple): + """Test fixture for teamocil config conversion tests.""" + + test_id: str + teamocil_yaml: str + teamocil_dict: dict[str, t.Any] + tmuxp_dict: dict[str, t.Any] + + +TEAMOCIL_CONFIG_TEST_FIXTURES: list[TeamocilConfigTestFixture] = [ + TeamocilConfigTestFixture( + test_id="test1", + teamocil_yaml=fixtures.test1.teamocil_yaml, + teamocil_dict=fixtures.test1.teamocil_conf, + tmuxp_dict=fixtures.test1.expected, + ), + TeamocilConfigTestFixture( + test_id="test2", + teamocil_yaml=fixtures.test2.teamocil_yaml, + teamocil_dict=fixtures.test2.teamocil_dict, + tmuxp_dict=fixtures.test2.expected, + ), + TeamocilConfigTestFixture( + test_id="test3", + teamocil_yaml=fixtures.test3.teamocil_yaml, + teamocil_dict=fixtures.test3.teamocil_dict, + tmuxp_dict=fixtures.test3.expected, + ), + TeamocilConfigTestFixture( + test_id="test4", + teamocil_yaml=fixtures.test4.teamocil_yaml, + teamocil_dict=fixtures.test4.teamocil_dict, + tmuxp_dict=fixtures.test4.expected, + ), +] + + +@pytest.mark.parametrize( + list(TeamocilConfigTestFixture._fields), + TEAMOCIL_CONFIG_TEST_FIXTURES, + ids=[test.test_id for test in TEAMOCIL_CONFIG_TEST_FIXTURES], +) +def test_config_to_dict( + test_id: str, + teamocil_yaml: str, + teamocil_dict: dict[str, t.Any], + tmuxp_dict: dict[str, t.Any], +) -> None: + """Test exporting teamocil configuration to dictionary.""" + yaml_to_dict = config_reader.ConfigReader._load( + fmt="yaml", + content=teamocil_yaml, + ) + assert yaml_to_dict == teamocil_dict + + assert importers.import_teamocil(teamocil_dict) == tmuxp_dict + + validation.validate_schema(importers.import_teamocil(teamocil_dict)) + + +@pytest.fixture(scope="module") +def multisession_config() -> dict[ + str, + dict[str, t.Any], +]: + """Return loaded multisession teamocil config as a dictionary. + + Also prevents re-running assertion the loads the yaml, since ordering of + deep list items like panes will be inconsistent. + """ + teamocil_yaml_file = fixtures.layouts.teamocil_yaml_file + test_config = config_reader.ConfigReader._from_file(teamocil_yaml_file) + teamocil_dict: dict[str, t.Any] = fixtures.layouts.teamocil_dict + + assert test_config == teamocil_dict + return teamocil_dict + + +class TeamocilMultiSessionTestFixture(t.NamedTuple): + """Test fixture for teamocil multisession config tests.""" + + test_id: str + session_name: str + expected: dict[str, t.Any] + + +TEAMOCIL_MULTISESSION_TEST_FIXTURES: list[TeamocilMultiSessionTestFixture] = [ + TeamocilMultiSessionTestFixture( + test_id="basic_two_windows", + session_name="two-windows", + expected=fixtures.layouts.two_windows, + ), + TeamocilMultiSessionTestFixture( + test_id="two_windows_with_filters", + session_name="two-windows-with-filters", + expected=fixtures.layouts.two_windows_with_filters, + ), + TeamocilMultiSessionTestFixture( + test_id="two_windows_with_custom_command_options", + session_name="two-windows-with-custom-command-options", + expected=fixtures.layouts.two_windows_with_custom_command_options, + ), + TeamocilMultiSessionTestFixture( + test_id="three_windows_within_session", + session_name="three-windows-within-a-session", + expected=fixtures.layouts.three_windows_within_a_session, + ), +] + + +@pytest.mark.parametrize( + list(TeamocilMultiSessionTestFixture._fields), + TEAMOCIL_MULTISESSION_TEST_FIXTURES, + ids=[test.test_id for test in TEAMOCIL_MULTISESSION_TEST_FIXTURES], +) +def test_multisession_config( + test_id: str, + session_name: str, + expected: dict[str, t.Any], + multisession_config: dict[str, t.Any], +) -> None: + """Test importing teamocil multisession configuration.""" + # teamocil can fit multiple sessions in a config + assert importers.import_teamocil(multisession_config[session_name]) == expected + + validation.validate_schema( + importers.import_teamocil(multisession_config[session_name]), + ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + 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" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py new file mode 100644 index 0000000000..457605f2ab --- /dev/null +++ b/tests/workspace/test_import_tmuxinator.py @@ -0,0 +1,78 @@ +"""Test for tmuxp tmuxinator configuration.""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest + +from tests.fixtures import import_tmuxinator as fixtures +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import importers, validation + + +class TmuxinatorConfigTestFixture(t.NamedTuple): + """Test fixture for tmuxinator config conversion tests.""" + + test_id: str + tmuxinator_yaml: str + tmuxinator_dict: dict[str, t.Any] + tmuxp_dict: dict[str, t.Any] + + +TMUXINATOR_CONFIG_TEST_FIXTURES: list[TmuxinatorConfigTestFixture] = [ + TmuxinatorConfigTestFixture( + test_id="basic_config", + tmuxinator_yaml=fixtures.test1.tmuxinator_yaml, + tmuxinator_dict=fixtures.test1.tmuxinator_dict, + tmuxp_dict=fixtures.test1.expected, + ), + TmuxinatorConfigTestFixture( + test_id="legacy_tabs_config", # older vers use `tabs` instead of `windows` + tmuxinator_yaml=fixtures.test2.tmuxinator_yaml, + tmuxinator_dict=fixtures.test2.tmuxinator_dict, + tmuxp_dict=fixtures.test2.expected, + ), + TmuxinatorConfigTestFixture( + test_id="sample_config", # Test importing + tmuxinator_yaml=fixtures.test3.tmuxinator_yaml, + tmuxinator_dict=fixtures.test3.tmuxinator_dict, + tmuxp_dict=fixtures.test3.expected, + ), +] + + +@pytest.mark.parametrize( + list(TmuxinatorConfigTestFixture._fields), + TMUXINATOR_CONFIG_TEST_FIXTURES, + ids=[test.test_id for test in TMUXINATOR_CONFIG_TEST_FIXTURES], +) +def test_config_to_dict( + test_id: str, + tmuxinator_yaml: str, + tmuxinator_dict: dict[str, t.Any], + tmuxp_dict: dict[str, t.Any], +) -> None: + """Test exporting tmuxinator configuration to dictionary.""" + yaml_to_dict = ConfigReader._load(fmt="yaml", content=tmuxinator_yaml) + assert yaml_to_dict == tmuxinator_dict + + assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict + + validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + 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" diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py new file mode 100644 index 0000000000..c7c9aa369b --- /dev/null +++ b/tests/workspace/test_options.py @@ -0,0 +1,150 @@ +"""Tests for workspace builder options (:mod:`tmuxp.workspace.options`).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, PaneReadiness.AUTO), + ("auto", PaneReadiness.AUTO), + ("AUTO", PaneReadiness.AUTO), + (True, PaneReadiness.ALWAYS), + ("always", PaneReadiness.ALWAYS), + ("on", PaneReadiness.ALWAYS), + ("yes", PaneReadiness.ALWAYS), + ("1", PaneReadiness.ALWAYS), + (1, PaneReadiness.ALWAYS), + (False, PaneReadiness.NEVER), + ("never", PaneReadiness.NEVER), + ("off", PaneReadiness.NEVER), + ("no", PaneReadiness.NEVER), + ("0", PaneReadiness.NEVER), + (0, PaneReadiness.NEVER), + (PaneReadiness.ALWAYS, PaneReadiness.ALWAYS), + ], +) +def test_pane_readiness_from_config(value: t.Any, expected: PaneReadiness) -> None: + """from_config maps canonical values and truthy/falsy aliases.""" + assert PaneReadiness.from_config(value) is expected + + +@pytest.mark.parametrize("value", ["sometimes", "maybe", "2", ""]) +def test_pane_readiness_from_config_invalid(value: str) -> None: + """from_config rejects unknown values with an actionable error.""" + with pytest.raises(ValueError, match="pane_readiness"): + PaneReadiness.from_config(value) + + +def test_workspace_builder_options_defaults() -> None: + """An absent catalog yields AUTO readiness.""" + assert WorkspaceBuilderOptions.from_config({}).pane_readiness is PaneReadiness.AUTO + cfg = {"session_name": "x", "windows": []} + assert WorkspaceBuilderOptions.from_config(cfg).pane_readiness is PaneReadiness.AUTO + + +def test_workspace_builder_options_reads_catalog() -> None: + """from_config reads pane_readiness from the catalog.""" + cfg = {"workspace_builder_options": {"pane_readiness": "never"}} + options = WorkspaceBuilderOptions.from_config(cfg) + assert options.pane_readiness is PaneReadiness.NEVER + + +def test_workspace_builder_options_invalid_catalog_type() -> None: + """A non-mapping catalog is rejected as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="must be a mapping"): + WorkspaceBuilderOptions.from_config({"workspace_builder_options": ["nope"]}) + + +def test_workspace_builder_options_invalid_pane_readiness() -> None: + """An invalid pane_readiness is wrapped as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="pane_readiness"): + WorkspaceBuilderOptions.from_config( + {"workspace_builder_options": {"pane_readiness": "sometimes"}}, + ) + + +@pytest.mark.parametrize( + ("shell", "expected"), + [ + ("/usr/bin/zsh", True), + ("/bin/zsh", True), + ("zsh", True), + ("/bin/bash", False), + ("/bin/sh", False), + ("", False), + (None, False), + ], +) +def test_shell_is_zsh(shell: str | None, expected: bool) -> None: + """shell_is_zsh detects zsh by name.""" + assert shell_is_zsh(shell) is expected + + +class _FakeSession: + """Minimal stand-in exposing ``show_option`` for shell resolution.""" + + def __init__(self, shell: str | None) -> None: + self._shell = shell + + def show_option(self, name: str, **kwargs: t.Any) -> str | None: + """Return the canned default-shell value.""" + return self._shell + + +def test_resolve_session_shell_prefers_default_shell() -> None: + """The tmux default-shell wins over $SHELL.""" + shell = resolve_session_shell( + _FakeSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" + + +def test_resolve_session_shell_falls_back_to_env() -> None: + """$SHELL is the fallback when default-shell is empty.""" + shell = resolve_session_shell(_FakeSession(None), env={"SHELL": "/bin/bash"}) + assert shell == "/bin/bash" + + +def test_resolve_session_shell_empty_when_unknown() -> None: + """Returns an empty string when neither source resolves.""" + assert resolve_session_shell(_FakeSession(None), env={}) == "" + + +class _InheritedShellSession: + """Session whose default-shell is only visible with include_inherited.""" + + def __init__(self, shell: str) -> None: + self._shell = shell + + def show_option( + self, + name: str, + *, + include_inherited: bool = False, + **kwargs: t.Any, + ) -> str | None: + """Return the shell only when inherited options are requested.""" + return self._shell if include_inherited else None + + +def test_resolve_session_shell_reads_inherited_default_shell() -> None: + """A globally-inherited default-shell wins over the $SHELL fallback.""" + shell = resolve_session_shell( + _InheritedShellSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py new file mode 100644 index 0000000000..336fa9dafd --- /dev/null +++ b/tests/workspace/test_progress.py @@ -0,0 +1,258 @@ +"""Tests for tmuxp workspace builder progress callback.""" + +from __future__ import annotations + +import typing as t + +import pytest +from libtmux.server import Server + +from tmuxp import exc +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_builder_on_progress_callback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WorkspaceBuilder calls on_progress at each build milestone.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "progress-test", + "windows": [{"window_name": "editor", "panes": [{"shell_command": []}]}], + } + + calls: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_progress=calls.append, + ) + builder.build() + + assert any("Session created:" in c for c in calls) + assert any("Creating window:" in c for c in calls) + assert any("Creating pane:" in c for c in calls) + assert "Workspace built" in calls + + +def test_builder_on_before_script_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_before_script callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-callback-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + called: list[bool] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_before_script=lambda: called.append(True), + ) + builder.build() + assert called == [] + + +def test_builder_on_script_output_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_script_output callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-output-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + lines: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_script_output=lines.append, + ) + builder.build() + assert lines == [] + + +def test_builder_on_build_event_sequence( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_build_event fires the full event sequence during build().""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "build-event-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert event_types[0] == "session_created" + assert event_types[-1] == "workspace_built" + assert event_types.count("window_started") == 2 + assert event_types.count("window_done") == 2 + assert event_types.count("pane_creating") == 3 # 2 panes + 1 pane + + created = next(e for e in events if e["event"] == "session_created") + assert created["window_total"] == 2 + assert created["session_pane_total"] == 3 # 2 panes + 1 pane + + +def test_builder_on_build_event_session_name( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event carries correct session name.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "name-check", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["name"] == "name-check" + assert created["window_total"] == 1 + + +def test_builder_on_build_event_session_pane_total( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event includes session_pane_total summing all windows' panes.""" + monkeypatch.delenv("TMUX", raising=False) + + pane: dict[str, list[object]] = {"shell_command": []} + session_config = { + "session_name": "pane-total-test", + "windows": [ + {"window_name": "w1", "panes": [pane, pane]}, + {"window_name": "w2", "panes": [pane]}, + {"window_name": "w3", "panes": [pane, pane, pane]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["session_pane_total"] == 6 # 2 + 1 + 3 + + +def test_builder_before_script_events( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_started fires before run; before_script_done fires in finally.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-events-test", + "before_script": "echo hello", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + bs_start_idx = event_types.index("before_script_started") + bs_done_idx = event_types.index("before_script_done") + win_idx = event_types.index("window_started") + assert bs_start_idx < bs_done_idx < win_idx + + +def test_builder_before_script_done_fires_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_done fires in finally even when the script fails.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-fail-test", + "before_script": "/bin/false", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + with pytest.raises(exc.BeforeLoadScriptError): + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + +def test_builder_on_build_event_pane_numbers( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pane_creating events carry 1-based pane_num and correct pane_total.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "pane-num-test", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + pane_events = [e for e in events if e["event"] == "pane_creating"] + assert len(pane_events) == 3 + assert [e["pane_num"] for e in pane_events] == [1, 2, 3] + assert all(e["pane_total"] == 3 for e in pane_events) diff --git a/tmuxp/__about__.py b/tmuxp/__about__.py deleted file mode 100644 index 01676ddce3..0000000000 --- a/tmuxp/__about__.py +++ /dev/null @@ -1,8 +0,0 @@ -__title__ = 'tmuxp' -__package_name__ = 'tmuxp' -__version__ = '1.2.3' -__description__ = 'tmux session manager' -__email__ = 'tony@git-pull.com' -__author__ = 'Tony Narlock' -__license__ = 'BSD' -__copyright__ = 'Copyright 2013-2016 Tony Narlock' diff --git a/tmuxp/__init__.py b/tmuxp/__init__.py deleted file mode 100644 index a49fdb94ab..0000000000 --- a/tmuxp/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# flake8: NOQA -"""tmux session manager. - -tmuxp -~~~~~ - -:copyright: Copyright 2013 Tony Narlock. -:license: BSD, see LICENSE for details - -""" -from __future__ import absolute_import, division, print_function, \ - with_statement, unicode_literals - -from .__about__ import __title__, __package_name__, __version__, \ - __description__, __email__, __author__, __license__, __copyright__ - -from .workspacebuilder import WorkspaceBuilder - -from . import config, util, cli diff --git a/tmuxp/_compat.py b/tmuxp/_compat.py deleted file mode 100644 index 2cbef62450..0000000000 --- a/tmuxp/_compat.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf8 -*- -# flake8: NOQA -import sys - -PY2 = sys.version_info[0] == 2 - -_identity = lambda x: x - - -if PY2: - unichr = unichr - text_type = unicode - string_types = (str, unicode) - integer_types = (int, long) - from urllib import urlretrieve - - text_to_native = lambda s, enc: s.encode(enc) - - iterkeys = lambda d: d.iterkeys() - itervalues = lambda d: d.itervalues() - iteritems = lambda d: d.iteritems() - - from cStringIO import StringIO as BytesIO - from StringIO import StringIO - import cPickle as pickle - import ConfigParser as configparser - - from itertools import izip, imap - range_type = xrange - - cmp = cmp - - input = raw_input - from string import lower as ascii_lowercase - import urlparse - - exec('def reraise(tp, value, tb=None):\n raise tp, value, tb') - - def implements_to_string(cls): - cls.__unicode__ = cls.__str__ - cls.__str__ = lambda x: x.__unicode__().encode('utf-8') - return cls - - def console_to_str(s): - return s.decode('utf_8') - -else: - unichr = chr - text_type = str - string_types = (str,) - integer_types = (int, ) - - text_to_native = lambda s, enc: s - - iterkeys = lambda d: iter(d.keys()) - itervalues = lambda d: iter(d.values()) - iteritems = lambda d: iter(d.items()) - - from io import StringIO, BytesIO - import pickle - import configparser - - izip = zip - imap = map - range_type = range - - cmp = lambda a, b: (a > b) - (a < b) - - input = input - from string import ascii_lowercase - import urllib.parse as urllib - import urllib.parse as urlparse - from urllib.request import urlretrieve - - console_encoding = sys.__stdout__.encoding - - implements_to_string = _identity - - def console_to_str(s): - """ From pypa/pip project, pip.backwardwardcompat. License MIT. """ - try: - return s.decode(console_encoding) - except UnicodeDecodeError: - return s.decode('utf_8') - - def reraise(tp, value, tb=None): - if value.__traceback__ is not tb: - raise(value.with_traceback(tb)) - raise value - - -number_types = integer_types + (float,) diff --git a/tmuxp/cli.py b/tmuxp/cli.py deleted file mode 100644 index fce70eb9f2..0000000000 --- a/tmuxp/cli.py +++ /dev/null @@ -1,640 +0,0 @@ -# -*- coding: utf-8 -*- -"""Command line tool for managing tmux workspaces and tmuxp configurations. - -tmuxp.cli -~~~~~~~~~ - -""" -from __future__ import absolute_import, print_function, with_statement - -import logging -import os -import sys - -import click -import kaptan -from click.exceptions import FileError -from libtmux.common import has_required_tmux_version, which -from libtmux.server import Server - -from . import WorkspaceBuilder, config, exc, log, util -from .__about__ import __version__ -from ._compat import string_types -from .workspacebuilder import freeze - -logger = logging.getLogger(__name__) - - -def get_config_dir(): - return os.path.expanduser('~/.tmuxp/') - - -def get_cwd(): - return os.getcwd() - - -def get_tmuxinator_dir(): - return os.path.expanduser('~/.tmuxinator/') - - -def get_teamocil_dir(): - return os.path.expanduser('~/.teamocil/') - - -def _validate_choices(options): - """Callback wrapper for validating click.prompt input. - - :param options: List of allowed choices - :type options: list - :rtype: func - :returns: function for value_proc in :func:`click.prompt`. - """ - - def func(value): - if value not in options: - raise click.BadParameter( - 'Possible choices are: {0}.'.format(', '.join(options))) - return value - - return func - - -def is_pure_name(path): - """Return True if path is a name and not a file path.""" - return ( - not os.path.isabs(path) and - len(os.path.dirname(path)) == 0 and - not os.path.splitext(path)[1] and - path != '.' and path != '' - ) - - -def _create_resolve_config_argument(config_dir): - """For finding configurations in tmuxinator/teamocil""" - def func(ctx, param, value): - return resolve_config_argument(ctx, param, value, config_dir) - - return func - - -def resolve_config_argument(ctx, param, value, config_dir=None): - """Validate / translate config name/path values for click config arg. - - Wrapper on top of :func:`cli.resolve_config`.""" - if callable(config_dir): - config_dir = config_dir() - - if not config: - click.echo("Enter at least one CONFIG") - click.echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - if isinstance(value, string_types): - value = resolve_config(value, config_dir=config_dir) - - elif isinstance(value, tuple): - value = tuple( - [resolve_config(v, config_dir=config_dir) for v in value]) - - return value - - -def get_abs_path(config): - path = os.path - join, isabs = path.join, path.isabs - dirname, normpath = path.dirname, path.normpath - cwd = os.getcwd() - - config = os.path.expanduser(config) - if not isabs(config) or len(dirname(config)) > 1: - config = normpath(join(cwd, config)) - - return config - - -def _resolve_path_no_overwrite(config): - path = get_abs_path(config) - if os.path.exists(path): - raise click.exceptions.UsageError( - '%s exists. Pick a new filename.' % path) - return path - - -def resolve_config(config, config_dir=None): - """Return the real config path or raise an exception. - - :param config: config file, valid examples: - - a file name, myconfig.yaml - - relative path, ../config.yaml or ../project - - a period, . - :type config: string - - If config is directory, scan for .tmuxp.{yaml,yml,json} in directory. If - one or more found, it will warn and pick the first. - - If config is ".", "./" or None, it will scan current directory. - - If config is has no path and only a filename, e.g. "myconfig.yaml" it will - search config dir. - - If config has no path and only a name with no extension, e.g. "myconfig", - it will scan for file name with yaml, yml and json. If multiple exist, it - will warn and pick the first. - - :raises: :class:`click.exceptions.FileError` - """ - if not config_dir: - config_dir = get_config_dir() - path = os.path - exists, join, isabs = path.exists, path.join, path.isabs - dirname, normpath, splitext = path.dirname, path.normpath, path.splitext - cwd = os.getcwd() - is_name = False - file_error = None - - config = os.path.expanduser(config) - # if purename, resolve to confg dir - if is_pure_name(config): - is_name = True - elif ( - not isabs(config) or len(dirname(config)) > 1 or config == '.' or - config == "" or config == "./" - ): # if relative, fill in full path - config = normpath(join(cwd, config)) - - # no extension, scan - if not splitext(config)[1]: - if is_name: - candidates = [ - x for x in ['%s%s' % (join(config_dir, config), ext) - for ext in ['.yaml', '.yml', '.json'] - ] if exists(x) - ] - if not len(candidates): - file_error = ( - 'config not found in config dir (yaml/yml/json) %s ' - 'for name' % (config_dir)) - else: - candidates = [ - x for x in [ - join(config, ext) for ext in - ['.tmuxp.yaml', '.tmuxp.yml', '.tmuxp.json'] - ] if exists(x) - ] - - if len(candidates) > 1: - click.secho( - 'Multiple .tmuxp.{yml,yaml,json} configs in %s' % - dirname(config), fg="red") - click.echo(click.wrap_text( - 'This is undefined behavior, use only one. ' - 'Use file names e.g. myproject.json, coolproject.yaml. ' - 'You can load them by filename.' - )) - elif not len(candidates): - file_error = 'No tmuxp files found in directory' - if len(candidates): - config = candidates[0] - elif not exists(config): - file_error = 'file not found' - - if file_error: - raise FileError(file_error, config) - - return config - - -def load_workspace( - config_file, socket_name=None, socket_path=None, colors=None, - attached=None, detached=None, answer_yes=False -): - """Build config workspace. - - :param config_file: full path to config file - :param type: string - - """ - - sconfig = kaptan.Kaptan() - sconfig = sconfig.import_config(config_file).get() - # expands configurations relative to config / profile file location - sconfig = config.expand(sconfig, os.path.dirname(config_file)) - sconfig = config.trickle(sconfig) - - t = Server( - socket_name=socket_name, - socket_path=socket_path, - colors=colors - ) - - try: - builder = WorkspaceBuilder(sconf=sconfig, server=t) - except exc.EmptyConfigException: - click.echo('%s is empty or parsed no config data' % - config_file, err=True) - return - - which('tmux') - - def reattach(session): - if 'TMUX' in os.environ: - session.switch_client() - - else: - session.attach_session() - - session_name = sconfig['session_name'] - if builder.session_exists(session_name): - if not detached and ( - answer_yes or click.confirm( - '%s is already running. Attach?' % - click.style(session_name, fg='green'), default=True) - ): - reattach(builder.session) - return - - try: - click.echo( - click.style('[Loading] ', fg='green') + - click.style(config_file, fg='blue', bold=True)) - builder.build() - - if 'TMUX' in os.environ: - if not detached and (answer_yes or click.confirm( - 'Already inside TMUX, switch to session?' - )): - tmux_env = os.environ.pop('TMUX') - builder.session.switch_client() - - os.environ['TMUX'] = tmux_env - return builder.session - else: - sys.exit('Session created in detached state.') - - if not detached: - builder.session.attach_session() - except exc.TmuxpException as e: - import traceback - - click.echo(traceback.format_exc(), err=True) - click.echo(e, err=True) - - choice = click.prompt( - 'Error loading workspace. (k)ill, (a)ttach, (d)etach?', - value_proc=_validate_choices(['k', 'a', 'd']), - default='k' - ) - - if choice == 'k': - builder.session.kill_session() - click.echo('Session killed.') - elif choice == 'a': - if 'TMUX' in os.environ: - builder.session.switch_client() - else: - builder.session.attach_session() - else: - sys.exit() - - return builder.session - - -@click.group(context_settings={'obj': {}}) -@click.version_option( - __version__, '-V', '--version', message='%(prog)s %(version)s' -) -@click.option('--log_level', default='INFO', - help='Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)') -def cli(log_level): - """Manage tmux sessions. - - Pass the "--help" argument to any command to see detailed help. - See detailed documentation and examples at: - http://tmuxp.readthedocs.io/en/latest/""" - try: - has_required_tmux_version() - except exc.TmuxpException as e: - click.echo(e, err=True) - sys.exit() - setup_logger( - level=log_level.upper() - ) - - -def setup_logger(logger=None, level='INFO'): - """Setup logging for CLI use. - - :param logger: instance of logger - :type logger: :py:class:`Logger` - - """ - if not logger: - logger = logging.getLogger() - if not logger.handlers: - channel = logging.StreamHandler() - channel.setFormatter(log.DebugLogFormatter()) - - # channel.setFormatter(log.LogFormatter()) - logger.setLevel(level) - logger.addHandler(channel) - - -def startup(config_dir): - """Initialize CLI. - - :param get_config_dir(): Config directory to search - :type get_config_dir(): string - - """ - - if not os.path.exists(config_dir): - os.makedirs(config_dir) - - -@cli.command(name='freeze') -@click.argument('session_name', nargs=1) -@click.option('-S', 'socket_path', help='pass-through for tmux -L') -@click.option('-L', 'socket_name', help='pass-through for tmux -L') -def command_freeze(session_name, socket_name, socket_path): - """Snapshot a session into a config. - - If SESSION_NAME is provided, snapshot that session. Otherwise, use the - current session.""" - - t = Server( - socket_name=socket_name, - socket_path=socket_path, - ) - - try: - session = t.find_where({ - 'session_name': session_name - }) - - if not session: - raise exc.TmuxpException('Session not found.') - except exc.TmuxpException as e: - print(e) - return - - sconf = freeze(session) - configparser = kaptan.Kaptan() - newconfig = config.inline(sconf) - configparser.import_config(newconfig) - config_format = click.prompt( - 'Convert to', - value_proc=_validate_choices(['yaml', 'json']), - default='yaml' - ) - - if config_format == 'yaml': - newconfig = configparser.export( - 'yaml', indent=2, default_flow_style=False, safe=True - ) - elif config_format == 'json': - newconfig = configparser.export('json', indent=2) - else: - sys.exit('Unknown config format.') - - print(newconfig) - print( - '---------------------------------------------------------------' - '\n' - 'Freeze does it best to snapshot live tmux sessions.\n' - ) - if click.confirm( - 'The new config *WILL* require adjusting afterwards. Save config?' - ): - dest = None - while not dest: - save_to = os.path.abspath( - os.path.join( - get_config_dir(), - '%s.%s' % (sconf.get('session_name'), config_format) - ) - ) - dest_prompt = click.prompt( - 'Save to: %s' % - save_to, value_proc=get_abs_path, - default=save_to, confirmation_prompt=True) - if os.path.exists(dest_prompt): - print('%s exists. Pick a new filename.' % dest_prompt) - continue - - dest = dest_prompt - - dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) - if click.confirm('Save to %s?' % dest): - destdir = os.path.dirname(dest) - if not os.path.isdir(destdir): - os.makedirs(destdir) - buf = open(dest, 'w') - buf.write(newconfig) - buf.close() - - print('Saved to %s.' % dest) - else: - print( - 'tmuxp has examples in JSON and YAML format at ' - '\n' - 'View tmuxp docs at .' - ) - sys.exit() - - -@cli.command(name='load', short_help='Load tmuxp workspaces.') -@click.pass_context -@click.argument('config', click.Path(exists=True), nargs=-1, - callback=resolve_config_argument) -@click.option('-S', 'socket_path', help='pass-through for tmux -L') -@click.option('-L', 'socket_name', help='pass-through for tmux -L') -@click.option('--yes', '-y', 'answer_yes', help='yes', is_flag=True) -@click.option('-d', 'detached', - help='Load the session without attaching it', is_flag=True) -@click.option( - '-2', 'colors', flag_value=256, default=True, - help='Force tmux to assume the terminal supports 256 colours.') -@click.option( - '-8', 'colors', flag_value=88, - help='Like -2, but indicates that the terminal supports 88 colours.') -def command_load(ctx, config, socket_name, socket_path, answer_yes, - detached, colors): - """Load a tmux workspace from each CONFIG. - - CONFIG is a specifier for a configuration file. - - If CONFIG is a path to a directory, tmuxp will search it for - ".tmuxp.{yaml,yml,json}". - - If CONFIG is has no directory component and only a filename, e.g. - "myconfig.yaml", tmuxp will search the users's config directory for that - file. - - If CONFIG has no directory component, and only a name with no extension, - e.g. "myconfig", tmuxp will search the users's config directory for any - file with the extension ".yaml", ".yml", or ".json" that matches that name. - - If multiple configuration files that match a given CONFIG are found, tmuxp - will warn and pick the first one found. - - If multiple CONFIGs are provided, workspaces will be created for all of - them. The last one provided will be attached. The others will be created in - detached mode. - """ - util.oh_my_zsh_auto_title() - - tmux_options = { - 'socket_name': socket_name, - 'socket_path': socket_path, - 'answer_yes': answer_yes, - 'colors': colors, - 'detached': detached - } - - if not config: - click.echo("Enter at least one CONFIG") - click.echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - if isinstance(config, string_types): - load_workspace(config, **tmux_options) - - elif isinstance(config, tuple): - config = list(config) - # Load each configuration but the last to the background - for cfg in config[:-1]: - opt = tmux_options.copy() - opt.update({'detached': True}) - load_workspace(cfg, **opt) - - # todo: obey the -d in the cli args only if user specifies - load_workspace(config[-1], **tmux_options) - - -@cli.group(name='import') -def import_config_cmd(): - """Import a teamocil/tmuxinator config.""" - pass - - -def import_config(configfile, importfunc): - configparser = kaptan.Kaptan(handler='yaml') - - configparser.import_config(configfile) - newconfig = importfunc(configparser.get()) - configparser.import_config(newconfig) - - config_format = click.prompt( - 'Convert to', - value_proc=_validate_choices(['yaml', 'json']), - default='yaml' - ) - - if config_format == 'yaml': - newconfig = configparser.export( - 'yaml', indent=2, default_flow_style=False - ) - elif config_format == 'json': - newconfig = configparser.export('json', indent=2) - else: - sys.exit('Unknown config format.') - - click.echo( - newconfig + - '---------------------------------------------------------------' - '\n' - 'Configuration import does its best to convert files.\n' - ) - if click.confirm( - 'The new config *WILL* require adjusting afterwards. Save config?' - ): - dest = None - while not dest: - dest_path = click.prompt( - 'Save to [%s]' % os.getcwd(), - value_proc=_resolve_path_no_overwrite,) - - # dest = dest_prompt - if click.confirm('Save to %s?' % dest_path): - dest = dest_path - - buf = open(dest, 'w') - buf.write(newconfig) - buf.close() - - click.echo('Saved to %s.' % dest) - else: - click.echo( - 'tmuxp has examples in JSON and YAML format at ' - '\n' - 'View tmuxp docs at ' - ) - sys.exit() - - -@import_config_cmd.command(name='teamocil', - short_help='Convert and import a teamocil config.') -@click.argument( - 'configfile', click.Path(exists=True), nargs=1, - callback=_create_resolve_config_argument(get_teamocil_dir) -) -def command_import_teamocil(configfile): - """Convert a teamocil config from CONFIGFILE to tmuxp format and import - it into tmuxp.""" - - import_config(configfile, config.import_teamocil) - - -@import_config_cmd.command( - name='tmuxinator', - short_help='Convert and import a tmuxinator config.') -@click.argument( - 'configfile', click.Path(exists=True), nargs=1, - callback=_create_resolve_config_argument(get_tmuxinator_dir) -) -def command_import_tmuxinator(configfile): - """Convert a tmuxinator config from CONFIGFILE to tmuxp format and import - it into tmuxp.""" - import_config(configfile, config.import_tmuxinator) - - -@cli.command(name='convert') -@click.argument('config', click.Path(exists=True), nargs=1, - callback=resolve_config_argument) -def command_convert(config): - """Convert a tmuxp config between JSON and YAML.""" - - _, ext = os.path.splitext(config) - if 'json' in ext: - if click.confirm( - 'convert to <%s> to yaml?' % config - ): - configparser = kaptan.Kaptan() - configparser.import_config(config) - newfile = config.replace(ext, '.yaml') - newconfig = configparser.export( - 'yaml', indent=2, default_flow_style=False - ) - if click.confirm( - 'Save config to %s?' % newfile - ): - buf = open(newfile, 'w') - buf.write(newconfig) - buf.close() - print('New config saved to %s' % newfile) - elif 'yaml' in ext: - if click.confirm( - 'convert to <%s> to json?' % config - ): - configparser = kaptan.Kaptan() - configparser.import_config(config) - newfile = config.replace(ext, '.json') - newconfig = configparser.export('json', indent=2) - print(newconfig) - if click.confirm( - 'Save config to <%s>?' % newfile - ): - buf = open(newfile, 'w') - buf.write(newconfig) - buf.close() - print('New config saved to <%s>.' % newfile) diff --git a/tmuxp/config.py b/tmuxp/config.py deleted file mode 100644 index 20aa9dd7f7..0000000000 --- a/tmuxp/config.py +++ /dev/null @@ -1,541 +0,0 @@ -# -*- coding: utf-8 -*- -"""Configuration parsing and export for tmuxp. - -tmuxp.config -~~~~~~~~~~~~ - -""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import copy -import logging -import os - -from . import exc -from ._compat import string_types - -logger = logging.getLogger(__name__) - - -def validate_schema(sconf): - """Return True if config schema is correct. - - :param sconf: session configuration - :type sconf: dict - :rtype: bool - - """ - - # verify session_name - if 'session_name' not in sconf: - raise exc.ConfigError('config requires "session_name"') - - if 'windows' not in sconf: - raise exc.ConfigError('config requires list of "windows"') - - for window in sconf['windows']: - if 'window_name' not in window: - raise exc.ConfigError('config window is missing "window_name"') - - if 'panes' not in window: - raise exc.ConfigError( - 'config window %s requires list of panes' % - window['window_name'] - ) - - return True - - -def is_config_file(filename, extensions=['.yml', '.yaml', '.json']): - """Return True if file has a valid config file type. - - :param filename: filename to check (e.g. ``mysession.json``). - :type filename: string - :param extensions: filetypes to check (e.g. ``['.yaml', '.json']``). - :type extensions: list or string - :rtype: bool - - """ - - extensions = [extensions] if isinstance( - extensions, string_types) else extensions - return any(filename.endswith(e) for e in extensions) - - -def in_dir( - config_dir=os.path.expanduser('~/.tmuxp'), - extensions=['.yml', '.yaml', '.json'] -): - """Return a list of configs in ``config_dir``. - - :param config_dir: directory to search - :type config_dir: string - :param extensions: filetypes to check (e.g. ``['.yaml', '.json']``). - :type extensions: list - :rtype: list - - """ - configs = [] - - for filename in os.listdir(config_dir): - if is_config_file(filename, extensions) and \ - not filename.startswith('.'): - configs.append(filename) - - return configs - - -def in_cwd(): - """Return list of configs in current working directory. - - If filename is ``.tmuxp.py``, ``.tmuxp.json``, ``.tmuxp.yaml``. - - :rtype: list - - """ - configs = [] - - for filename in os.listdir(os.getcwd()): - if filename.startswith('.tmuxp') and is_config_file(filename): - configs.append(filename) - - return configs - - -def expandshell(_path): - """Return expanded path based on user's ``$HOME`` and ``env``. - - :py:func:`os.path.expanduser` and :py:func:`os.path.expandvars` - - :param _path: path to expand - :type _path: string - :returns: expanded path - :rtype: string - - """ - return os.path.expandvars(os.path.expanduser(_path)) - - -def inline(sconf): - """ Return config in inline form, opposite of :meth:`config.expand`. - - :param sconf: unexpanded config file - :type sconf: dict - :rtype: dict - - """ - - if ( - 'shell_command' in sconf and - isinstance(sconf['shell_command'], list) and - len(sconf['shell_command']) == 1 - ): - sconf['shell_command'] = sconf['shell_command'][0] - - if len(sconf.keys()) == int(1): - sconf = sconf['shell_command'] - if ( - 'shell_command_before' in sconf and - isinstance(sconf['shell_command_before'], list) and - len(sconf['shell_command_before']) == 1 - ): - sconf['shell_command_before'] = sconf['shell_command_before'][0] - - # recurse into window and pane config items - if 'windows' in sconf: - sconf['windows'] = [ - inline(window) for window in sconf['windows'] - ] - if 'panes' in sconf: - sconf['panes'] = [inline(pane) for pane in sconf['panes']] - - return sconf - - -def expand(sconf, cwd=None, parent=None): - """Return config with shorthand and inline properties expanded. - - This is necessary to keep the code in the :class:`WorkspaceBuilder` clean - and also allow for neat, short-hand configurations. - - As a simple example, internally, tmuxp expects that config options - like ``shell_command`` are a list (array):: - - 'shell_command': ['htop'] - - tmuxp configs allow for it to be simply a string:: - - 'shell_command': 'htop' - - Kaptan will load JSON/YAML files into python dicts for you. - - :param sconf: the configuration for the session - :type sconf: dict - :param cwd: directory to expand relative paths against. should be the dir - of the config directory. - :type cwd: string - :param parent: (used on recursive entries) start_directory of parent window - or session object. - :type parent: str - :rtype: dict - - """ - - # Note: cli.py will expand configs relative to project's config directory - # for the first cwd argument. - if not cwd: - cwd = os.getcwd() - - if 'session_name' in sconf: - sconf['session_name'] = expandshell(sconf['session_name']) - if 'window_name' in sconf: - sconf['window_name'] = expandshell(sconf['window_name']) - if 'environment' in sconf: - for key in sconf['environment']: - val = sconf['environment'][key] - val = expandshell(val) - if any(val.startswith(a) for a in ['.', './']): - val = os.path.normpath(os.path.join(cwd, val)) - sconf['environment'][key] = val - if 'global_options' in sconf: - for key in sconf['global_options']: - val = sconf['global_options'][key] - if isinstance(val, string_types): - val = expandshell(val) - if any(val.startswith(a) for a in ['.', './']): - val = os.path.normpath(os.path.join(cwd, val)) - sconf['global_options'][key] = val - if 'options' in sconf: - for key in sconf['options']: - val = sconf['options'][key] - if isinstance(val, string_types): - val = expandshell(val) - if any(val.startswith(a) for a in ['.', './']): - val = os.path.normpath(os.path.join(cwd, val)) - sconf['options'][key] = val - - # Any config section, session, window, pane that can contain the - # 'shell_command' value - if 'start_directory' in sconf: - sconf['start_directory'] = expandshell(sconf['start_directory']) - start_path = sconf['start_directory'] - if any(start_path.startswith(a) for a in ['.', './']): - # if window has a session, or pane has a window with a - # start_directory of . or ./, make sure the start_directory can be - # relative to the parent. - # - # This is for the case where you may be loading a config from - # outside your shell current directory. - if parent: - cwd = parent['start_directory'] - start_path = os.path.normpath(os.path.join(cwd, start_path)) - sconf['start_directory'] = start_path - - if 'before_script' in sconf: - sconf['before_script'] = expandshell(sconf['before_script']) - if any(sconf['before_script'].startswith(a) for a in ['.', './']): - sconf['before_script'] = os.path.normpath( - os.path.join(cwd, sconf['before_script']) - ) - - if ( - 'shell_command' in sconf and - isinstance(sconf['shell_command'], string_types) - ): - sconf['shell_command'] = [sconf['shell_command']] - - if ( - 'shell_command_before' in sconf and - isinstance(sconf['shell_command_before'], string_types) - ): - sconf['shell_command_before'] = [sconf['shell_command_before']] - - if ( - 'shell_command_before' in sconf and - isinstance(sconf['shell_command_before'], list) - ): - sconf['shell_command_before'] = [ - expandshell(scmd) for scmd in sconf['shell_command_before'] - ] - - # recurse into window and pane config items - if 'windows' in sconf: - sconf['windows'] = [ - expand(window, parent=sconf) for window in sconf['windows'] - ] - elif 'panes' in sconf: - - for pconf in sconf['panes']: - p_index = sconf['panes'].index(pconf) - p = copy.deepcopy(pconf) - pconf = sconf['panes'][p_index] = {} - - if isinstance(p, string_types): - p = { - 'shell_command': [p] - } - elif not p: - p = { - 'shell_command': [] - } - - assert isinstance(p, dict) - if 'shell_command' in p: - cmd = p['shell_command'] - - if isinstance(p['shell_command'], string_types): - cmd = [cmd] - - if not cmd or any(a == cmd for a in [None, 'blank', 'pane']): - cmd = [] - - if isinstance(cmd, list) and len(cmd) == int(1): - if any(a in cmd for a in [None, 'blank', 'pane']): - cmd = [] - - p['shell_command'] = cmd - else: - p['shell_command'] = [] - - pconf.update(p) - sconf['panes'] = [ - expand(pane, parent=sconf) for pane in sconf['panes'] - ] - - return sconf - - -def trickle(sconf): - """Return a dict with "trickled down" / inherited config values. - - This will only work if config has been expanded to full form with - :meth:`config.expand`. - - tmuxp allows certain commands to be default at the session, window - level. shell_command_before trickles down and prepends the - ``shell_command`` for the pane. - - :param sconf: the session configuration - :type sconf: dict - :rtype: dict - - """ - - # prepends a pane's ``shell_command`` list with the window and sessions' - # ``shell_command_before``. - - if 'start_directory' in sconf: - session_start_directory = sconf['start_directory'] - else: - session_start_directory = None - - if 'suppress_history' in sconf: - suppress_history = sconf['suppress_history'] - else: - suppress_history = None - - for windowconfig in sconf['windows']: - - # Prepend start_directory to relative window commands - if session_start_directory: - if 'start_directory' not in windowconfig: - windowconfig['start_directory'] = session_start_directory - else: - if not any( - windowconfig['start_directory'].startswith(a) - for a in ['~', '/'] - ): - window_start_path = os.path.join( - session_start_directory, - windowconfig['start_directory'] - ) - windowconfig['start_directory'] = window_start_path - - # We only need to trickle to the window, workspace builder checks wconf - if suppress_history is not None: - if 'suppress_history' not in windowconfig: - windowconfig['suppress_history'] = suppress_history - - for paneconfig in windowconfig['panes']: - commands_before = [] - - # Prepend shell_command_before to commands - if 'shell_command_before' in sconf: - commands_before.extend(sconf['shell_command_before']) - if 'shell_command_before' in windowconfig: - commands_before.extend(windowconfig['shell_command_before']) - if 'shell_command_before' in paneconfig: - commands_before.extend(paneconfig['shell_command_before']) - - if 'shell_command' in paneconfig: - commands_before.extend(paneconfig['shell_command']) - - p_index = windowconfig['panes'].index(paneconfig) - windowconfig['panes'][p_index]['shell_command'] = commands_before - # paneconfig['shell_command'] = commands_before - - return sconf - - -def import_tmuxinator(sconf): - """Return tmuxp config from a `tmuxinator`_ yaml config. - - .. _tmuxinator: https://github.com/aziz/tmuxinator - - :param sconf: python dict for session configuration - :type sconf: dict - :rtype: dict - - """ - - tmuxp_config = {} - - if 'project_name' in sconf: - tmuxp_config['session_name'] = sconf.pop('project_name') - elif 'name' in sconf: - tmuxp_config['session_name'] = sconf.pop('name') - else: - tmuxp_config['session_name'] = None - - if 'project_root' in sconf: - tmuxp_config['start_directory'] = sconf.pop('project_root') - elif 'root' in sconf: - tmuxp_config['start_directory'] = sconf.pop('root') - - if 'cli_args' in sconf: - tmuxp_config['config'] = sconf['cli_args'] - - if '-f' in tmuxp_config['config']: - tmuxp_config['config'] = tmuxp_config[ - 'config' - ].replace('-f', '').strip() - elif 'tmux_options' in sconf: - tmuxp_config['config'] = sconf['tmux_options'] - - if '-f' in tmuxp_config['config']: - tmuxp_config['config'] = tmuxp_config[ - 'config' - ].replace('-f', '').strip() - - if 'socket_name' in sconf: - tmuxp_config['socket_name'] = sconf['socket_name'] - - tmuxp_config['windows'] = [] - - if 'tabs' in sconf: - sconf['windows'] = sconf.pop('tabs') - - if 'pre' in sconf and 'pre_window' in sconf: - tmuxp_config['shell_command'] = sconf['pre'] - - if isinstance(sconf['pre'], string_types): - tmuxp_config['shell_command_before'] = [sconf['pre_window']] - else: - tmuxp_config['shell_command_before'] = sconf['pre_window'] - elif 'pre' in sconf: - if isinstance(sconf['pre'], string_types): - tmuxp_config['shell_command_before'] = [sconf['pre']] - else: - tmuxp_config['shell_command_before'] = sconf['pre'] - - if 'rbenv' in sconf: - if 'shell_command_before' not in tmuxp_config: - tmuxp_config['shell_command_before'] = [] - tmuxp_config['shell_command_before'].append( - 'rbenv shell %s' % sconf['rbenv'] - ) - - for w in sconf['windows']: - for k, v in w.items(): - - windowdict = {'window_name': k} - - if isinstance(v, string_types) or v is None: - windowdict['panes'] = [v] - tmuxp_config['windows'].append(windowdict) - continue - elif isinstance(v, list): - windowdict['panes'] = v - tmuxp_config['windows'].append(windowdict) - continue - - if 'pre' in v: - windowdict['shell_command_before'] = v['pre'] - if 'panes' in v: - windowdict['panes'] = v['panes'] - if 'root' in v: - windowdict['start_directory'] = v['root'] - - if 'layout' in v: - windowdict['layout'] = v['layout'] - tmuxp_config['windows'].append(windowdict) - return tmuxp_config - - -def import_teamocil(sconf): - """Return tmuxp config from a `teamocil`_ yaml config. - - .. _teamocil: https://github.com/remiprev/teamocil - - :todo: change 'root' to a cd or start_directory - :todo: width in pane -> main-pain-width - :todo: with_env_var - :todo: clear - :todo: cmd_separator - - :param sconf: python dict for session configuration - :type sconf: dict - - """ - - tmuxp_config = {} - - if 'session' in sconf: - sconf = sconf['session'] - - if 'name' in sconf: - tmuxp_config['session_name'] = sconf['name'] - else: - tmuxp_config['session_name'] = None - - if 'root' in sconf: - tmuxp_config['start_directory'] = sconf.pop('root') - - tmuxp_config['windows'] = [] - - for w in sconf['windows']: - - windowdict = {'window_name': w['name']} - - if 'clear' in w: - windowdict['clear'] = w['clear'] - - if 'filters' in w: - if 'before' in w['filters']: - for b in w['filters']['before']: - windowdict['shell_command_before'] = w['filters']['before'] - if 'after' in w['filters']: - for b in w['filters']['after']: - windowdict['shell_command_after'] = w['filters']['after'] - - if 'root' in w: - windowdict['start_directory'] = w.pop('root') - - if 'splits' in w: - w['panes'] = w.pop('splits') - - if 'panes' in w: - for p in w['panes']: - if 'cmd' in p: - p['shell_command'] = p.pop('cmd') - if 'width' in p: - # todo support for height/width - p.pop('width') - windowdict['panes'] = w['panes'] - - if 'layout' in w: - windowdict['layout'] = w['layout'] - tmuxp_config['windows'].append(windowdict) - - return tmuxp_config diff --git a/tmuxp/exc.py b/tmuxp/exc.py deleted file mode 100644 index 0c9061d026..0000000000 --- a/tmuxp/exc.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- -"""Exceptions for tmuxp. - -tmuxp.exc -~~~~~~~~~ - -""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -from ._compat import implements_to_string - - -class TmuxpException(Exception): - - """Base Exception for Tmuxp Errors.""" - - -class ConfigError(TmuxpException): - - """Error parsing tmuxp configuration dict.""" - - pass - - -class EmptyConfigException(ConfigError): - - """Configuration is empty.""" - - pass - - -class BeforeLoadScriptNotExists(OSError): - - def __init__(self, *args, **kwargs): - super(BeforeLoadScriptNotExists, self).__init__(*args, **kwargs) - - self.strerror = ( - "before_script file '%s' doesn't exist." % self.strerror - ) - - -@implements_to_string -class BeforeLoadScriptError(Exception): - - """Exception replacing :py:class:`subprocess.CalledProcessError` for - :meth:`util.run_before_script`. - """ - - def __init__(self, returncode, cmd, output=None): - self.returncode = returncode - self.cmd = cmd - self.output = output - self.message = ( - 'before_script failed with returncode {returncode}.\n' - 'command: {cmd}\n' - 'Error output:\n' - '{output}' - ).format(returncode=self.returncode, cmd=self.cmd, output=self.output) - - def __str__(self): - return self.message diff --git a/tmuxp/log.py b/tmuxp/log.py deleted file mode 100644 index 7c7be63afc..0000000000 --- a/tmuxp/log.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -"""Log utilities for tmuxp. - -tmuxp.log -~~~~~~~~~ - -""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import logging -import time - -from colorama import Fore, Style - -LEVEL_COLORS = { - 'DEBUG': Fore.BLUE, # Blue - 'INFO': Fore.GREEN, # Green - 'WARNING': Fore.YELLOW, - 'ERROR': Fore.RED, - 'CRITICAL': Fore.RED -} - - -def default_log_template(self, record): - """Return the prefix for the log message. Template for Formatter. - - :param: record: :py:class:`logging.LogRecord` object. this is passed in - from inside the :py:meth:`logging.Formatter.format` record. - - """ - - reset = Style.RESET_ALL - levelname = ( - LEVEL_COLORS.get(record.levelname) + Style.BRIGHT + - '(%(levelname)s)' + Style.RESET_ALL + ' ' - ) - asctime = ( - '[' + Fore.BLACK + Style.DIM + Style.BRIGHT + - '%(asctime)s' + Fore.RESET + Style.RESET_ALL + ']' - ) - name = ( - ' ' + Fore.WHITE + Style.DIM + Style.BRIGHT + - '%(name)s' + Fore.RESET + Style.RESET_ALL + ' ' - ) - - tpl = reset + levelname + asctime + name + reset - - return tpl - - -class LogFormatter(logging.Formatter): - template = default_log_template - - def __init__(self, color=True, *args, **kwargs): - logging.Formatter.__init__(self, *args, **kwargs) - - def format(self, record): - try: - record.message = record.getMessage() - except Exception as e: - record.message = "Bad message (%r): %r" % (e, record.__dict__) - - date_format = '%H:%m:%S' - record.asctime = time.strftime( - date_format, self.converter(record.created) - ) - - prefix = self.template(record) % record.__dict__ - - formatted = prefix + " " + record.message - return formatted.replace("\n", "\n ") - - -def debug_log_template(self, record): - """ Return the prefix for the log message. Template for Formatter. - - :param: record: :py:class:`logging.LogRecord` object. this is passed in - from inside the :py:meth:`logging.Formatter.format` record. - - """ - - reset = Style.RESET_ALL - levelname = ( - LEVEL_COLORS.get(record.levelname) + Style.BRIGHT + - '(%(levelname)1.1s)' + Style.RESET_ALL + ' ' - ) - asctime = ( - '[' + Fore.BLACK + Style.DIM + Style.BRIGHT + - '%(asctime)s' + Fore.RESET + Style.RESET_ALL + ']' - ) - name = ( - ' ' + Fore.WHITE + Style.DIM + Style.BRIGHT + - '%(name)s' + Fore.RESET + Style.RESET_ALL + ' ' - ) - module_funcName = ( - Fore.GREEN + Style.BRIGHT + - '%(module)s.%(funcName)s()' - ) - lineno = ( - Fore.BLACK + Style.DIM + Style.BRIGHT + ':' + Style.RESET_ALL + - Fore.CYAN + '%(lineno)d' - ) - - tpl = reset + levelname + asctime + name + module_funcName + lineno + reset - - return tpl - - -class DebugLogFormatter(LogFormatter): - """Provides greater technical details than standard log Formatter.""" - - template = debug_log_template diff --git a/tmuxp/util.py b/tmuxp/util.py deleted file mode 100644 index cb75b40158..0000000000 --- a/tmuxp/util.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -"""Utility and helper methods for tmuxp. - -tmuxp.util -~~~~~~~~~~ - -""" -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import logging -import os -import shlex -import subprocess -import sys - -from . import exc -from ._compat import console_to_str - -logger = logging.getLogger(__name__) - -PY2 = sys.version_info[0] == 2 - - -def run_before_script(script_file): - """Function to wrap try/except for subprocess.check_call().""" - try: - proc = subprocess.Popen( - shlex.split(str(script_file)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE - ) - for line in iter(proc.stdout.readline, b''): - sys.stdout.write(console_to_str(line)) - proc.wait() - - if proc.returncode: - stderr = proc.stderr.read() - proc.stderr.close() - stderr = console_to_str(stderr).split('\n') - stderr = '\n'.join(list(filter(None, stderr))) # filter empty - - raise exc.BeforeLoadScriptError( - proc.returncode, os.path.abspath(script_file), stderr - ) - - return proc.returncode - except OSError as e: - if e.errno == 2: - raise exc.BeforeLoadScriptNotExists( - e, os.path.abspath(script_file) - ) - else: - raise e - - -def oh_my_zsh_auto_title(): - """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. - - see: https://github.com/robbyrussell/oh-my-zsh/pull/257 - - """ - - if 'SHELL' in os.environ and 'zsh' in os.environ.get('SHELL'): - if os.path.exists(os.path.expanduser('~/.oh-my-zsh')): - # oh-my-zsh exists - if ( - 'DISABLE_AUTO_TITLE' not in os.environ or - os.environ.get('DISABLE_AUTO_TITLE') == "false" - ): - print('Please set:\n\n' - '\texport DISABLE_AUTO_TITLE = \'true\'\n\n' - 'in ~/.zshrc or where your zsh profile is stored.\n' - 'Remember the "export" at the beginning!\n\n' - 'Then create a new shell or type:\n\n' - '\t$ source ~/.zshrc') diff --git a/tmuxp/workspacebuilder.py b/tmuxp/workspacebuilder.py deleted file mode 100644 index 12bb47018b..0000000000 --- a/tmuxp/workspacebuilder.py +++ /dev/null @@ -1,377 +0,0 @@ -# -*- coding: utf8 -*- -"""Create a tmux workspace from a configuration :py:obj:`dict`. - -tmuxp.workspacebuilder -~~~~~~~~~~~~~~~~~~~~~~ - -""" - -from __future__ import (absolute_import, division, print_function, - unicode_literals, with_statement) - -import logging - -from libtmux.exc import TmuxSessionExists -from libtmux.pane import Pane -from libtmux.server import Server -from libtmux.session import Session -from libtmux.window import Window - -from . import exc -from .util import run_before_script - -logger = logging.getLogger(__name__) - - -class WorkspaceBuilder(object): - - """Load workspace from session :py:obj:`dict`. - - Build tmux workspace from a configuration. Creates and names windows, sets - options, splits windows into panes. - - The normal phase of loading is: - - 1. :term:`kaptan` imports json/yaml/ini. ``.get()`` returns python - :class:`dict`:: - - import kaptan - sconf = kaptan.Kaptan(handler='yaml') - sconf = sconfig.import_config(self.yaml_config).get() - - or from config file with extension:: - - import kaptan - sconf = kaptan.Kaptan() - sconf = sconfig.import_config('path/to/config.yaml').get() - - kaptan automatically detects the handler from filenames. - - 2. :meth:`config.expand` sconf inline shorthand:: - - from tmuxp import config - sconf = config.expand(sconf) - - 3. :meth:`config.trickle` passes down default values from session - -> window -> pane if applicable:: - - sconf = config.trickle(sconf) - - 4. (You are here) We will create a :class:`Session` (a real - ``tmux(1)`` session) and iterate through the list of windows, and - their panes, returning full :class:`Window` and :class:`Pane` - objects each step of the way:: - - workspace = WorkspaceBuilder(sconf=sconf) - - It handles the magic of cases where the user may want to start - a session inside tmux (when `$TMUX` is in the env variables). - - """ - - def __init__(self, sconf, server=None): - """Initialize workspace loading. - - :todo: initialize :class:`Session` from here, in ``self.session``. - - :param sconf: session config, includes a :py:obj:`list` of ``windows``. - :type sconf: :py:obj:`dict` - - :param server: - :type server: :class:`Server` - - """ - - if not sconf: - raise exc.EmptyConfigException('session configuration is empty.') - - # config.validate_schema(sconf) - - if isinstance(server, Server): - self.server = server - else: - self.server = None - - self.sconf = sconf - - def session_exists(self, session_name=None): - exists = self.server.has_session(session_name) - if not exists: - return exists - - self.session = self.server.find_where( - { - 'session_name': session_name - } - ) - return True - - def build(self, session=None): - """Build tmux workspace in session. - - Optionally accepts ``session`` to build with only session object. - - Without ``session``, it will use :class:`Server` at ``self.server`` - passed in on initialization to create a new Session object. - - :param session: - session to build workspace in - :type session: :class:`Session` - - """ - - if not session: - if not self.server: - raise exc.TmuxpException( - 'WorkspaceBuilder.build requires server to be passed ' + - 'on initialization, or pass in session object to here.' - ) - - if self.server.has_session(self.sconf['session_name']): - self.session = self.server.find_where( - { - 'session_name': self.sconf['session_name'] - } - ) - raise TmuxSessionExists( - 'Session name %s is already running.' % - self.sconf['session_name'] - ) - else: - session = self.server.new_session( - session_name=self.sconf['session_name'] - ) - - assert(self.sconf['session_name'] == session.name) - assert(len(self.sconf['session_name']) > 0) - - self.session = session - self.server = session.server - - self.server._list_sessions() - assert self.server.has_session(session.name) - assert session.id - - assert(isinstance(session, Session)) - - focus = None - - if 'before_script' in self.sconf: - try: - run_before_script(self.sconf['before_script']) - except Exception as e: - self.session.kill_session() - raise e - if 'options' in self.sconf: - for option, value in self.sconf['options'].items(): - self.session.set_option(option, value) - if 'global_options' in self.sconf: - for option, value in self.sconf['global_options'].items(): - self.session.set_option(option, value, g=True) - if 'environment' in self.sconf: - for option, value in self.sconf['environment'].items(): - self.session.set_environment(option, value) - - for w, wconf in self.iter_create_windows(session): - assert(isinstance(w, Window)) - - focus_pane = None - for p, pconf in self.iter_create_panes(w, wconf): - assert(isinstance(p, Pane)) - p = p - - if 'layout' in wconf: - w.select_layout(wconf['layout']) - - if 'focus' in pconf and pconf['focus']: - focus_pane = p - - if 'focus' in wconf and wconf['focus']: - focus = w - - if focus_pane: - focus_pane.select_pane() - - if focus: - focus.select_window() - - def iter_create_windows(self, s): - """Return :class:`Window` iterating through session config dict. - - Generator yielding :class:`Window` by iterating through - ``sconf['windows']``. - - Applies ``window_options`` to window. - - :param session: :class:`Session` from the config - :rtype: tuple(:class:`Window`, ``wconf``) - - """ - for i, wconf in enumerate(self.sconf['windows'], start=1): - if 'window_name' not in wconf: - window_name = None - else: - window_name = wconf['window_name'] - - w1 = None - if i == int(1): # if first window, use window 1 - w1 = s.attached_window - w1.move_window(99) - pass - - if 'start_directory' in wconf: - sd = wconf['start_directory'] - else: - sd = None - - if 'window_shell' in wconf: - ws = wconf['window_shell'] - else: - ws = None - - w = s.new_window( - window_name=window_name, - start_directory=sd, - attach=False, # do not move to the new window - window_index=wconf.get('window_index', ''), - window_shell=ws, - ) - - if i == int(1) and w1: # if first window, use window 1 - w1.kill_window() - assert(isinstance(w, Window)) - s.server._update_windows() - if 'options' in wconf and isinstance(wconf['options'], dict): - for key, val in wconf['options'].items(): - w.set_window_option(key, val) - - if 'focus' in wconf and wconf['focus']: - w.select_window() - - s.server._update_windows() - - yield w, wconf - - def iter_create_panes(self, w, wconf): - """Return :class:`Pane` iterating through window config dict. - - Run ``shell_command`` with ``$ tmux send-keys``. - - :param w: window to create panes for - :type w: :class:`Window` - :param wconf: config section for window - :type wconf: :py:obj:`dict` - :rtype: tuple(:class:`Pane`, ``pconf``) - - """ - assert(isinstance(w, Window)) - - pane_base_index = int(w.show_window_option('pane-base-index', g=True)) - - for pindex, pconf in enumerate(wconf['panes'], start=pane_base_index): - - if pindex == int(pane_base_index): - p = w.attached_pane - - else: - def get_pane_start_directory(): - - if 'start_directory' in pconf: - return pconf['start_directory'] - elif 'start_directory' in wconf: - return wconf['start_directory'] - else: - return None - p = w.split_window( - attach=True, - start_directory=get_pane_start_directory(), - ) - - assert(isinstance(p, Pane)) - if 'layout' in wconf: - w.select_layout(wconf['layout']) - - if 'suppress_history' in pconf: - suppress = pconf['suppress_history'] - elif 'suppress_history' in wconf: - suppress = wconf['suppress_history'] - else: - suppress = True - - for cmd in pconf['shell_command']: - p.send_keys(cmd, suppress_history=suppress) - - if 'focus' in pconf and pconf['focus']: - w.select_pane(p['pane_id']) - - w.server._update_panes() - - yield p, pconf - - -def freeze(session): - """Freeze live tmux session and Return session config :py:obj:`dict`. - - :param session: session object - :type session: :class:`Session` - :rtype: dict - - """ - sconf = {'session_name': session['session_name'], 'windows': []} - - for w in session.windows: - wconf = { - 'options': w.show_window_options(), - 'window_name': w.name, - 'layout': w.layout, - 'panes': [] - } - if w.get('window_active', '0') == '1': - wconf['focus'] = 'true' - - # If all panes have same path, set 'start_directory' instead - # of using 'cd' shell commands. - def pane_has_same_path(p): - return ( - w.panes[0].current_path == - p.current_path - ) - - if all(pane_has_same_path(p) for p in w.panes): - wconf['start_directory'] = w.panes[0].current_path - - for p in w.panes: - pconf = {'shell_command': []} - - if 'start_directory' not in wconf: - pconf['shell_command'].append( - 'cd ' + p.current_path - ) - - if p.get('pane_active', '0') == '1': - pconf['focus'] = 'true' - - current_cmd = p.current_command - - def filter_interpretters_and_shells(): - return ( - current_cmd.startswith('-') or - any( - current_cmd.endswith(cmd) - for cmd in ['python', 'ruby', 'node'] - ) - ) - - if filter_interpretters_and_shells(): - current_cmd = None - - if current_cmd: - pconf['shell_command'].append(current_cmd) - else: - if not len(pconf['shell_command']): - pconf = 'pane' - - wconf['panes'].append(pconf) - - sconf['windows'].append(wconf) - - return sconf diff --git a/tox.ini b/tox.ini deleted file mode 100644 index c3102c4c0c..0000000000 --- a/tox.ini +++ /dev/null @@ -1,5 +0,0 @@ -[tox] -envlist = py26, py27, py33, py34, py35 - -[testenv] -commands = make test [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..4ff83b3877 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2136 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4.0" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[options.exclude-newer-package] +sphinx-gp-mermaid = false +libtmux = false +gp-sphinx = false +sphinx-autodoc-sphinx = false +sphinx-autodoc-api-style = false +sphinx-autodoc-fastmcp = false +sphinx-autodoc-pytest-fixtures = false +sphinx-gp-highlighting = false +sphinx-autodoc-argparse = false +sphinx-gp-llms = false +sphinx-ux-autodoc-layout = false +sphinx-ux-badges = false +gp-libs = false +sphinx-autodoc-docutils = false +gp-furo-theme = false +sphinx-gp-sitemap = false +sphinx-fonts = false +sphinx-autodoc-typehints-gp = false +sphinx-gp-opengraph = false +sphinx-gp-theme = false + +[[package]] +name = "aafigure" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/13/8516bd4dd1520c02797e4544bfac7a03521b28bb9404588197af2803ad89/aafigure-0.6.tar.gz", hash = "sha256:49f2c1fd2b579c1fffbac1386a2670b3f6f475cc7ff6cc04d8b984888c2d9e1e", size = 52292, upload-time = "2017-06-06T00:58:07.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/82/a58d09930e357b196c40e2224de6b0bfc3d15d3f880c56dbf55c67c353d9/aafigure-0.6-py2.py3-none-any.whl", hash = "sha256:c7f9409eee58d8484696a1c9e75b4c6ceab9fd060223007a19dcc6f515a30696", size = 27165, upload-time = "2017-06-06T00:58:04.894Z" }, +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "codecov" +version = "2.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416, upload-time = "2023-04-17T23:11:39.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512, upload-time = "2023-04-17T23:11:37.344Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "gp-furo-theme" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/1e/06ca39c8eed45e401dcb56304fc400afb2614010721315febe838e4b3cb4/gp_furo_theme-0.0.1a35.tar.gz", hash = "sha256:d2fb40acebc215fd36345adbcd6d36f454b7f8047206d7f44f5068fbae688ff9", size = 34309, upload-time = "2026-07-19T21:54:01.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/db/a2ad42d667bd9a028dfcdf3c7bc97bfc761d6ae0c4299dbabd9441ae08cb/gp_furo_theme-0.0.1a35-py3-none-any.whl", hash = "sha256:08a9a87584ac3f971e398deb21468ca63a3243d71907c7c5c01d1f80d3eea257", size = 43660, upload-time = "2026-07-19T21:53:36.455Z" }, +] + +[[package]] +name = "gp-libs" +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/e3/947c93526c57541d7876b3c0177372220a904b4d8fa4024c54a47506a1c6/gp_libs-0.0.19.tar.gz", hash = "sha256:26fa76740dcfcc37b071903572b3fb3bfc71d2b545e07e7e63977838a3b2393e", size = 16022, upload-time = "2026-07-14T01:21:11.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/62/f5ad23c155267d145c96ba3485f638956b818f32ba03c7ad3fc31236c01c/gp_libs-0.0.19-py3-none-any.whl", hash = "sha256:f79c3cf4a7f646b26784c599fa39919257df7add43fc004e602777adc0e59f1e", size = 16694, upload-time = "2026-07-14T01:21:10.404Z" }, +] + +[[package]] +name = "gp-sphinx" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints-gp" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-fonts" }, + { name = "sphinx-gp-llms" }, + { name = "sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap" }, + { name = "sphinx-gp-theme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-rediraffe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/6a/92bc71e25c9a4dae7bac61ce526514a63dc6229012d89c92762a0d5064af/gp_sphinx-0.0.1a35.tar.gz", hash = "sha256:44cfbbd30950f574fdc50beec1c966c1eaa1ac7e71b21bdb775b960d516cc97e", size = 19761, upload-time = "2026-07-19T21:54:02.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/b2/cd304cc360cdb6771ad63f4084257959bf6bb50b15c0f41a3549e7845f09/gp_sphinx-0.0.1a35-py3-none-any.whl", hash = "sha256:d8772bb635e4001368c1432a5a97b2042e207e381f947e8317df31caf0fac011", size = 20277, upload-time = "2026-07-19T21:53:37.966Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "libtmux" +version = "0.61.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/c2/6abbac4420c35b3f1140c72607117236b160173f74fd86941f99b28375d5/libtmux-0.61.0.tar.gz", hash = "sha256:2d6081081a629b9236a36a64f874667533811d2ce5a1b0caa9c821f4d9d2e618", size = 546122, upload-time = "2026-07-04T12:57:36.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/14/934d0d9076d1d46fcc71f3397daea7921363781674337084b15d1bbcbf84/libtmux-0.61.0-py3-none-any.whl", hash = "sha256:b9315db6600757f840a8f16438725103e579aaa4be3c32728c105f2c284330df", size = 118059, upload-time = "2026-07-04T12:57:34.598Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, +] + +[[package]] +name = "pytest-watcher" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "roman-numerals" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, +] + +[[package]] +name = "sphinx-autodoc-api-style" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-ux-autodoc-layout" }, + { name = "sphinx-ux-badges" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/11/5b740da185145e5514ca768ed482de5ee2cbffd2f4d239a610eb6ab5e7b3/sphinx_autodoc_api_style-0.0.1a35.tar.gz", hash = "sha256:39a3a3ebbeef23e7caae58bb437da1edb30af9a68c328a35198622993dc24c5a", size = 9453, upload-time = "2026-07-19T21:54:03.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ec/8c984b39cc57ca21b2581f4095f03025ec19f10016aa4a5398041da51142/sphinx_autodoc_api_style-0.0.1a35-py3-none-any.whl", hash = "sha256:45c6a77e0f8df4c4d18c5be55d3db57c00690eb76bce0bf1fa67de1dd64a3a12", size = 9569, upload-time = "2026-07-19T21:53:39.479Z" }, +] + +[[package]] +name = "sphinx-autodoc-argparse" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/07/ae5226132714963082214ef73dd39e31f58c05ebe79cf5b4c690eab7b4ee/sphinx_autodoc_argparse-0.0.1a35.tar.gz", hash = "sha256:2e668dac2f115017bd8f95c5997ec174833159589577096b1ebb591b4ca6ed7a", size = 42803, upload-time = "2026-07-19T21:54:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/28/8d823a8ad5be0dcba4e62a9f9aa72985b0b7647b5cfc7c185fe7fcf36dd2/sphinx_autodoc_argparse-0.0.1a35-py3-none-any.whl", hash = "sha256:582329af0204874216cf081f5d6a31f6a4d3c070cfa6bdf5b03b0de506ee2309", size = 47654, upload-time = "2026-07-19T21:53:40.753Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints-gp" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6e/f9341eb41928e4512f011b480cc40db51f10718b8458981e7d774b8d7166/sphinx_autodoc_typehints_gp-0.0.1a35.tar.gz", hash = "sha256:38897bb5d3cb1d356cd86283594bf67853df279260493a3860e043c6eaf4cf65", size = 36865, upload-time = "2026-07-19T21:54:09.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e4/78eab0d6113aa6c6051efa0bb19fde3e16f76722ad765bfe1a80e69bf1e8/sphinx_autodoc_typehints_gp-0.0.1a35-py3-none-any.whl", hash = "sha256:fdbd3e3e63c6690f8e0a56abc167e6c74964b5e30a0ee6fbdf931a3aee7ac6ba", size = 39049, upload-time = "2026-07-19T21:53:47.797Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinx-fonts" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/db/db42563a8b22886241c00d319cb7c7297c074ce5d45ff4ed23f2fa4b001f/sphinx_fonts-0.0.1a35.tar.gz", hash = "sha256:13a1c83a02c0f14547f0295d8d9d9f5728ba5fb19793b319dbadddace714ee4c", size = 5788, upload-time = "2026-07-19T21:54:10.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/73/875faebf651c63eb29462ae181b70e164b707d53b245770ab8aa74c93ed2/sphinx_fonts-0.0.1a35-py3-none-any.whl", hash = "sha256:c1a78e43591937d32dab3e4335cfd810f1b3ccc656e2deb873b10d55b179f07d", size = 4360, upload-time = "2026-07-19T21:53:48.957Z" }, +] + +[[package]] +name = "sphinx-gp-highlighting" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/8e/7a169e2f3f8015d1e2af4921186cc032a8cd29e119a33cbb84f426f8a8db/sphinx_gp_highlighting-0.0.1a35.tar.gz", hash = "sha256:d51ddbc437b5458a8c39f7a09761b8332fa9599f6f10468686574cf4f4936ddf", size = 7453, upload-time = "2026-07-19T21:54:11.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/6a/b31a5d0614af78f6d13601d2501a6766b245b395e9d395f99a8f7bfdd135/sphinx_gp_highlighting-0.0.1a35-py3-none-any.whl", hash = "sha256:93fb5de31af957c7fa0ecb0d0448c3f098ccd6ddcd32c480d0ac5a10441c28b0", size = 7388, upload-time = "2026-07-19T21:53:50.117Z" }, +] + +[[package]] +name = "sphinx-gp-llms" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/9c/e44a783c0efcf05a0c0589dc66e82d76754a6d4873da3f869c4183fc4b7f/sphinx_gp_llms-0.0.1a35.tar.gz", hash = "sha256:6b511c2f3e7301087ef5c994b9805882a95064a1bb9c40a8bb849528f5103d62", size = 9911, upload-time = "2026-07-19T21:54:12.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/44/81938643bfb8ecfd06fbd0e55326e0f0924b2f0aa06856f14ecaacaa1485/sphinx_gp_llms-0.0.1a35-py3-none-any.whl", hash = "sha256:7f245717fd75218e1cf430fdc774a184a455ace6ec52c6c67a0196e721a2d46c", size = 12638, upload-time = "2026-07-19T21:53:51.27Z" }, +] + +[[package]] +name = "sphinx-gp-mermaid" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/7a/2e2510a76ff3a6fb66307bbc4cdbb86ed98b91818559efdc281e3bd14897/sphinx_gp_mermaid-0.0.1a35.tar.gz", hash = "sha256:bdab75ef56933541839463efbbb064315ebe1e03234e925c60ff6cfa824d4494", size = 71320, upload-time = "2026-07-19T21:54:13.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/56/1301a894a30015d53e0868a4bf0305c76a0fdda88fd53069218ef8e3550f/sphinx_gp_mermaid-0.0.1a35-py3-none-any.whl", hash = "sha256:205e765bab7666d2bc4d8848d819b10a24886088a5bdc664fde49cb6704d3b86", size = 12816, upload-time = "2026-07-19T21:53:52.616Z" }, +] + +[[package]] +name = "sphinx-gp-opengraph" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/26f3f640d542dd0ce29fda607b7ba14e69be570361d977f1472483fb5ce5/sphinx_gp_opengraph-0.0.1a35.tar.gz", hash = "sha256:7cdff5b7bd301ef07f3a38c9506e89e2650fa21380f8bbf5e961aedc0bd9e91b", size = 11947, upload-time = "2026-07-19T21:54:13.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/a2/9df8761ba248add19fddf75ce83b2802bd1c753e7a835b9ed4ec75f9280c/sphinx_gp_opengraph-0.0.1a35-py3-none-any.whl", hash = "sha256:75c6a1bffcc21e9484fd5f65b0688cf6063bcf1648f122abd451cc4e7b7a9a80", size = 12185, upload-time = "2026-07-19T21:53:53.803Z" }, +] + +[[package]] +name = "sphinx-gp-sitemap" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/de/6f2ae4274b696d6ae4e19dc2323481c93ee45c7e7ac2fa84f7721042877f/sphinx_gp_sitemap-0.0.1a35.tar.gz", hash = "sha256:83d2ab5b307f3ef03215c0a51c0cae52d46c4bfead2d3c4343ba56d670d7c30f", size = 9956, upload-time = "2026-07-19T21:54:14.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/12/8e5cf2ce8cdae9ab3fbf4cd78ada87d0e8539ed6481d286cb3640d7d63da/sphinx_gp_sitemap-0.0.1a35-py3-none-any.whl", hash = "sha256:1852bddc1f67820394cd6c40be1040fc2e9830f63ba2786505c0d55591efb2ac", size = 8982, upload-time = "2026-07-19T21:53:54.969Z" }, +] + +[[package]] +name = "sphinx-gp-theme" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gp-furo-theme" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/33/c70d2736ad56f10dfda3086a5001aec03da53197aeb7289c3e20a4b53547/sphinx_gp_theme-0.0.1a35.tar.gz", hash = "sha256:8c699698cbe65fbdbe6d64201778c4339c4f9707feb6c618c8f4bad6dc191bf0", size = 18486, upload-time = "2026-07-19T21:54:15.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/d9/eed280cbae7110e6930b134605c2e6be76350b67b7fcbb47e25e9ea7b3e3/sphinx_gp_theme-0.0.1a35-py3-none-any.whl", hash = "sha256:5978fe159e467e3263383850331ff14d3f814631e060883764f68278068e0d36", size = 20106, upload-time = "2026-07-19T21:53:56.179Z" }, +] + +[[package]] +name = "sphinx-inline-tabs" +version = "2025.12.21.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" }, +] + +[[package]] +name = "sphinx-ux-autodoc-layout" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/75/b6b218eb91a7fd24710ebafd4e0be7e3a1a598a7adb389d330a47719345d/sphinx_ux_autodoc_layout-0.0.1a35.tar.gz", hash = "sha256:aec957122a4e20015fd93d5d313d883d934f22fb1f38ec6edcb19d475f6e10b9", size = 30602, upload-time = "2026-07-19T21:54:16.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/3bc568bb987755aeb2617054c2949f83ff552e05a8de6d33cf5602b2044b/sphinx_ux_autodoc_layout-0.0.1a35-py3-none-any.whl", hash = "sha256:69110289993ed8be86ba256c2f833f31e6b7628c858a5fa6842ed2d93a9a1ee6", size = 34999, upload-time = "2026-07-19T21:53:57.624Z" }, +] + +[[package]] +name = "sphinx-ux-badges" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/b6/3cda8ad1a84fd2712a8220a8c97b852e93ed88644dd118911ba7de348faa/sphinx_ux_badges-0.0.1a35.tar.gz", hash = "sha256:f2d322c0d1d8bd51804a10daa1ee9e93adab6b5977c13c85b6a7d5ed29efd770", size = 16723, upload-time = "2026-07-19T21:54:17.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/32/6fcaeda520e242521084220442a5cabefae075ec3477261e0530dacec9d7/sphinx_ux_badges-0.0.1a35-py3-none-any.whl", hash = "sha256:c73426d800e59d403411921cbc37db41bcee70501cb856e50a812c545263de05", size = 17487, upload-time = "2026-07-19T21:53:58.872Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sphinxext-rediraffe" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/a9/ab13d156049eea633f992424f3e92cb40e3f1b606bb6d01d40a27457d38a/sphinxext_rediraffe-0.3.0.tar.gz", hash = "sha256:f319b3ccb7c3c3b6f63ffa6fd3eeb171b6d272df55075a9e84364394f391f507", size = 22114, upload-time = "2025-09-28T15:31:53.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tmuxp" +version = "1.74.0" +source = { editable = "." } +dependencies = [ + { name = "libtmux" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +coverage = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "pytest-cov" }, +] +dev = [ + { name = "aafigure" }, + { name = "codecov" }, + { name = "coverage" }, + { name = "gp-libs" }, + { name = "gp-sphinx" }, + { name = "mypy" }, + { name = "pillow" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-highlighting" }, + { name = "sphinx-gp-mermaid" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +docs = [ + { name = "aafigure" }, + { name = "gp-libs" }, + { name = "gp-sphinx" }, + { name = "pillow" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-highlighting" }, + { name = "sphinx-gp-mermaid" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +testing = [ + { name = "gp-libs" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, +] + +[package.metadata] +requires-dist = [ + { name = "libtmux", specifier = "~=0.61.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +coverage = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "pytest-cov" }, +] +dev = [ + { name = "aafigure" }, + { name = "codecov" }, + { name = "coverage" }, + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "gp-sphinx", specifier = "==0.0.1a35" }, + { name = "mypy" }, + { name = "pillow" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a35" }, + { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-highlighting", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-mermaid", specifier = "==0.0.1a35" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +docs = [ + { name = "aafigure" }, + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "gp-sphinx", specifier = "==0.0.1a35" }, + { name = "pillow" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a35" }, + { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-highlighting", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-mermaid", specifier = "==0.0.1a35" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +testing = [ + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-docutils" +version = "0.22.3.20260712" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/24/f50d49be6d5ebbae29ff83418b6788fc9897fdfb54d56555c9ae4b15fb53/types_docutils-0.22.3.20260712.tar.gz", hash = "sha256:bed54a50136c8e7613c03ee1c51eb958b42754915df83535de356b974ca05877", size = 57848, upload-time = "2026-07-12T05:13:36.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/50/2b408d249cc7260b2296af68c395d90267c23288d1eb96765637eb6c1490/types_docutils-0.22.3.20260712-py3-none-any.whl", hash = "sha256:4bbc5cf949f8b1b1c7e1befa5a4d1c3bcad9f22f823538eea26ba7ff694fa38e", size = 91970, upload-time = "2026-07-12T05:13:35.141Z" }, +] + +[[package]] +name = "types-pygments" +version = "2.20.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-docutils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/66/3e27e8dbe72947d51355d1fcba222a55bf5f0770ee660e9cc9df0d1ce5d7/types_pygments-2.20.0.20260518.tar.gz", hash = "sha256:bcab233d0389cb0a91146eb860e7bdcbceaa0f30bee73cefc7b367129cc4a330", size = 21152, upload-time = "2026-05-18T06:07:26.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/d6/5f19f5b633af6a7666c6096fdd06b70f0d4a8f585af5e18a3ef58ad47ec1/types_pygments-2.20.0.20260518-py3-none-any.whl", hash = "sha256:e40728efd00c9da5936366648d5b3c55e72ed52bdd80495a96577b4d717c4fcb", size = 29002, upload-time = "2026-05-18T06:07:26.054Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +]