[
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\non:\n  push:\n    branches: [main]\nenv:\n  CARGO_INCREMENTAL: 0\n  CARGO_NET_RETRY: 10\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: short\n  RUSTUP_MAX_RETRIES: 10\n\njobs:\n  # Update release PR\n  release_please:\n    name: Release Please\n    runs-on: ubuntu-latest\n    outputs:\n      release_created: ${{ steps.release.outputs.release_created }}\n      tag_name: ${{ steps.release.outputs.tag_name }}\n    permissions:\n      contents: write\n      pull-requests: write\n    steps:\n      - uses: googleapis/release-please-action@v4\n        id: release\n        with:\n          release-type: rust\n\n  # Build sources for every OS\n  github_build:\n    name: Build release binaries\n    needs: release_please\n    if: ${{ needs.release_please.outputs.release_created == 'true' }}\n    strategy:\n      matrix:\n        include:\n          - target: x86_64-unknown-linux-gnu\n            os: ubuntu-latest\n            name: turm-x86_64-unknown-linux-gnu.tar.gz\n\n          - target: x86_64-unknown-linux-musl\n            os: ubuntu-latest\n            name: turm-x86_64-unknown-linux-musl.tar.gz\n\n          - target: i686-unknown-linux-musl\n            os: ubuntu-latest\n            name: turm-i686-unknown-linux-musl.tar.gz\n\n          - target: aarch64-unknown-linux-gnu\n            os: ubuntu-latest\n            name: turm-aarch64-unknown-linux-gnu.tar.gz\n\n          - target: aarch64-unknown-linux-musl\n            os: ubuntu-latest\n            name: turm-aarch64-unknown-linux-musl.tar.gz\n\n          - target: arm-unknown-linux-musleabihf\n            os: ubuntu-latest\n            name: turm-arm-unknown-linux-musleabihf.tar.gz\n\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Setup | Checkout\n        uses: actions/checkout@v5\n\n      - name: Setup | Rust\n        uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          override: true\n          profile: minimal\n          target: ${{ matrix.target }}\n\n      - name: Build | Build\n        uses: actions-rs/cargo@v1\n        with:\n          command: build\n          args: --release --locked --target ${{ matrix.target }}\n          use-cross: ${{ matrix.os == 'ubuntu-latest' }}\n\n      - name: Post Build | Prepare artifacts\n        run: |\n          cd target/${{ matrix.target }}/release\n          tar czvf ../../../${{ matrix.name }} turm\n          cd -\n\n      - name: Release | Upload artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ matrix.name }}\n          path: ${{ matrix.name }}\n\n  # Create GitHub release with Rust build targets and release notes\n  upload_artifacts:\n    name: Add Build Artifacts to Release\n    needs: [release_please, github_build]\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Setup | Artifacts\n        uses: actions/download-artifact@v4\n\n      - name: Setup | Checksums\n        run: for file in turm-*/turm-*; do openssl dgst -sha256 -r \"$file\" | awk '{print $1}' > \"${file}.sha256\"; done\n\n      - name: Build | Add Artifacts to Release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: turm-*/turm-*\n          tag_name: ${{ needs.release_please.outputs.tag_name }}\n\n  maturin_linux:\n    name: Build Maturin Linux\n    needs: release_please\n    if: ${{ needs.release_please.outputs.release_created == 'true' }}\n    runs-on: ${{ matrix.platform.runner }}\n    strategy:\n      matrix:\n        platform:\n          - runner: ubuntu-latest\n            target: x86_64\n          - runner: ubuntu-latest\n            target: x86\n          - runner: ubuntu-latest\n            target: aarch64\n          - runner: ubuntu-latest\n            target: armv7\n          - runner: ubuntu-latest\n            target: s390x\n          - runner: ubuntu-latest\n            target: ppc64le\n    steps:\n      - uses: actions/checkout@v5\n      - name: Build wheels\n        uses: PyO3/maturin-action@v1\n        with:\n          target: ${{ matrix.platform.target }}\n          args: --release --out dist\n          sccache: 'true'\n          manylinux: auto\n      - name: Upload wheels\n        uses: actions/upload-artifact@v4\n        with:\n          name: wheels-linux-${{ matrix.platform.target }}\n          path: dist\n\n  maturin_musllinux:\n    name: Build Maturin Linux musl\n    needs: release_please\n    if: ${{ needs.release_please.outputs.release_created == 'true' }}\n    runs-on: ${{ matrix.platform.runner }}\n    strategy:\n      matrix:\n        platform:\n          - runner: ubuntu-latest\n            target: x86_64\n          - runner: ubuntu-latest\n            target: x86\n          - runner: ubuntu-latest\n            target: aarch64\n          - runner: ubuntu-latest\n            target: armv7\n    steps:\n      - uses: actions/checkout@v5\n      - name: Build wheels\n        uses: PyO3/maturin-action@v1\n        with:\n          target: ${{ matrix.platform.target }}\n          args: --release --out dist\n          sccache: 'true'\n          manylinux: musllinux_1_2\n      - name: Upload wheels\n        uses: actions/upload-artifact@v4\n        with:\n          name: wheels-musllinux-${{ matrix.platform.target }}\n          path: dist\n          \n  maturin_release:\n    name: Maturin | Release\n    runs-on: ubuntu-latest\n    needs: [upload_artifacts, maturin_linux, maturin_musllinux] # only publish if everything else worked\n    steps:\n      - uses: actions/download-artifact@v4\n      - name: Publish to PyPI\n        uses: PyO3/maturin-action@v1\n        env:\n          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}\n        with:\n          command: upload\n          args: --non-interactive --skip-existing wheels-*/*\n          \n  cargo_publish:\n    name: Publish to crates.io\n    needs: [upload_artifacts, maturin_release] # only publish if everything else worked\n    runs-on: ubuntu-latest\n    env:\n      CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}\n    steps:\n    - uses: actions/checkout@v5\n    - name: Publish\n      run: cargo publish\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@v5\n    - name: Build\n      run: cargo build --verbose\n    - name: Run tests\n      run: cargo test --verbose\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\nscripts/mock-slurm/logs/*\n!scripts/mock-slurm/logs/.gitkeep\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"type\": \"lldb\",\n            \"request\": \"launch\",\n            \"name\": \"Debug executable 'turm'\",\n            \"cargo\": {\n                \"args\": [\n                    \"build\",\n                    \"--bin=turm\",\n                    \"--package=turm\"\n                ],\n                \"filter\": {\n                    \"name\": \"turm\",\n                    \"kind\": \"bin\"\n                }\n            },\n            \"args\": [],\n            \"cwd\": \"${workspaceFolder}\",\n            \"env\": {\n                \"RUST_BACKTRACE\": \"1\"\n            }\n        },\n        {\n            \"type\": \"lldb\",\n            \"request\": \"launch\",\n            \"name\": \"Debug unit tests in executable 'turm'\",\n            \"cargo\": {\n                \"args\": [\n                    \"test\",\n                    \"--no-run\",\n                    \"--bin=turm\",\n                    \"--package=turm\"\n                ],\n                \"filter\": {\n                    \"name\": \"turm\",\n                    \"kind\": \"bin\"\n                }\n            },\n            \"args\": [],\n            \"cwd\": \"${workspaceFolder}\",\n            \"env\": {\n                \"RUST_BACKTRACE\": \"1\"\n            }\n        }\n    ]\n}"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [0.14.0](https://github.com/karimknaebel/turm/compare/v0.13.1...v0.14.0) (2026-03-07)\n\n\n### Features\n\n* add edit time limit dialog ([e376f89](https://github.com/karimknaebel/turm/commit/e376f89014a47d8362a4a6ad15a38cecaaa029ef))\n* add signal picker for cancel action ([9562037](https://github.com/karimknaebel/turm/commit/9562037b72749a4c753de41d6d4dc0c1e49f52d9))\n* change signal list style ([cd7fa24](https://github.com/karimknaebel/turm/commit/cd7fa241752a02016648dad1c58daf854cdd930f))\n* show command errors in dialog (scancel, scontrol, ...) ([de4aed5](https://github.com/karimknaebel/turm/commit/de4aed57ddc0042faea4d9ecdddc8a9bc050e45c))\n\n## [0.13.1](https://github.com/karimknaebel/turm/compare/v0.13.0...v0.13.1) (2026-03-03)\n\n\n### Bug Fixes\n\n* use bracketed paste mode to prevent accidental inputs ([19967cb](https://github.com/karimknaebel/turm/commit/19967cb2fcfedf8197ed94f86f815db1557818b5))\n\n## [0.13.0](https://github.com/karimknaebel/turm/compare/v0.12.0...v0.13.0) (2026-02-14)\n\n\n### Features\n\n* add mouse support ([85bb9eb](https://github.com/karimknaebel/turm/commit/85bb9eb710a98281631a4e67ba11bd1f89e3abbd))\n\n## [0.12.0](https://github.com/karimknaebel/turm/compare/v0.11.0...v0.12.0) (2026-02-10)\n\n\n### Features\n\n* add job name to detail window ([#42](https://github.com/karimknaebel/turm/issues/42)) ([0e40ab4](https://github.com/karimknaebel/turm/commit/0e40ab486bea2630f1717cc31e6380446ff0b87f))\n\n## [0.11.0](https://github.com/karimknaebel/turm/compare/v0.10.0...v0.11.0) (2025-12-23)\n\n\n### Features\n\n* add g and G bindings to jump to first/last job ([2b92a8e](https://github.com/karimknaebel/turm/commit/2b92a8edb426dbec2c52e26127d8516a2e116dd2))\n* scroll job list with ctrl+d/u ([3fe52ea](https://github.com/karimknaebel/turm/commit/3fe52eacc9b8745456e382281b1c58c1d21792e2))\n\n\n### Bug Fixes\n\n* clean up jobs refresh ([cc7b29b](https://github.com/karimknaebel/turm/commit/cc7b29b2f17c13b182c2171911da756c5bfc549b))\n* clippy ([8cc6246](https://github.com/karimknaebel/turm/commit/8cc624612948bc0e57a5a14d73ac0f78c6040bdf))\n* clippy ([6644393](https://github.com/karimknaebel/turm/commit/6644393ae45c50438e6315e6efd5676ad0680786))\n* format ([bb28b2b](https://github.com/karimknaebel/turm/commit/bb28b2be8df5e53803a16847c1ce9c59f4458399))\n* job list height ([9fdd3d7](https://github.com/karimknaebel/turm/commit/9fdd3d736d5ef65a72bf33da0103e952303dd574))\n* preserve job selection across refresh ([e54ac01](https://github.com/karimknaebel/turm/commit/e54ac016bd844b545d53bccf1480a38214a690ed))\n* remove unnecessary job_list_state modification in ui func ([50a780c](https://github.com/karimknaebel/turm/commit/50a780c967b41459dcb81702a2c28c0065e4d44d))\n* reset terminal on panic ([bc1de57](https://github.com/karimknaebel/turm/commit/bc1de573353ab9cc19d123da1565b79e747da1c3)), closes [#52](https://github.com/karimknaebel/turm/issues/52)\n* revert calling scancel inside of thread ([741198f](https://github.com/karimknaebel/turm/commit/741198f5a10724b74e7f094743ae4d7a4ee1abed))\n* wait for scancel to finish ([8b11875](https://github.com/karimknaebel/turm/commit/8b1187543997c9b03e88a92d6183a0eb7f3b2edd))\n\n## [0.10.0](https://github.com/karimknaebel/turm/compare/v0.9.0...v0.10.0) (2025-12-12)\n\n\n### Features\n\n* rounded corners ([a689259](https://github.com/karimknaebel/turm/commit/a6892592723c50d2d7ce48b9379b497a573c3a68))\n\n## [0.9.0](https://github.com/karimknaebel/turm/compare/v0.8.0...v0.9.0) (2025-08-26)\n\n\n### Features\n\n* auto-select first job on start ([c39613c](https://github.com/karimknaebel/turm/commit/c39613c32ca18625807a56081655f14533d63d32))\n* show estimated start time for pending jobs ([6ce5f01](https://github.com/karimknaebel/turm/commit/6ce5f01730d400f1a9be0d97dd17100728ce72d0))\n\n\n### Bug Fixes\n\n* cargo warning ([8979926](https://github.com/karimknaebel/turm/commit/8979926149d07b7e78e2839d767679efb4d52c2b))\n\n## [0.8.0](https://github.com/karimknaebel/turm/compare/v0.7.3...v0.8.0) (2025-08-24)\n\n\n### Features\n\n* auto-refresh non-existing file paths until they are created ([3a3acc6](https://github.com/karimknaebel/turm/commit/3a3acc6480faf58dc93de31d031bd2a90db117e8))\n\n## [0.7.3](https://github.com/karimknaebel/turm/compare/v0.7.2...v0.7.3) (2024-07-28)\n\n\n### Miscellaneous Chores\n\n* release 0.7.3 ([ae8665b](https://github.com/karimknaebel/turm/commit/ae8665b25d68842dc1100f85aee643bc122ef52f))\n\n## [0.7.2](https://github.com/karimknaebel/turm/compare/v0.7.1...v0.7.2) (2024-07-28)\n\n\n### Bug Fixes\n\n* crash on resize ([96f4f16](https://github.com/karimknaebel/turm/commit/96f4f1683ee98547dadc610cf21f293858ba9d50))\n\n## [0.7.1](https://github.com/karimknaebel/turm/compare/v0.6.0...v0.7.1) (2024-07-28)\n\n\n### Features\n\n* pretty text wrapping ([51dc964](https://github.com/karimknaebel/turm/commit/51dc9645f506b89a0444db64cab6ddc0d2ecdaf0))\n* toggle log text wrapping. update deps ([5243a36](https://github.com/karimknaebel/turm/commit/5243a368c173070c58ce8a51bce56be9f916ec21))\n* truncated line indicator ([f347664](https://github.com/karimknaebel/turm/commit/f347664ecd94db785140eac296e37d66e203a81b))\n\n\n### Bug Fixes\n\n* correctly resolve relative log file paths ([0ecc902](https://github.com/karimknaebel/turm/commit/0ecc902f036244ed67d29eb686dcbf2c413ec51c))\n* crash on resize ([6dc3b1d](https://github.com/karimknaebel/turm/commit/6dc3b1d9f387d3b2accdc34b7e8a0c42995424c9))\n\n\n### Miscellaneous Chores\n\n* release 0.7.1 ([499a3a6](https://github.com/karimknaebel/turm/commit/499a3a69059adab68444d552acc4838962db4e0b))\n\n## [0.6.0](https://github.com/karimknaebel/turm/compare/v0.5.0...v0.6.0) (2023-09-23)\n\n\n### Features\n\n* toggle stdout/stderr ([bcd773b](https://github.com/karimknaebel/turm/commit/bcd773bd21ccb64860e651e2da881d57253fecb8))\n\n## [0.5.0](https://github.com/karimknaebel/turm/compare/v0.4.0...v0.5.0) (2023-09-15)\n\n\n### Features\n\n* show job count ([c169e18](https://github.com/karimknaebel/turm/commit/c169e1844574885246736dbde920ae0f77b121b2))\n\n## [0.4.0](https://github.com/karimknaebel/turm/compare/v0.3.0...v0.4.0) (2023-04-23)\n\n\n### Features\n\n* faster fast scrolling (shift/control/alt) ([37e205a](https://github.com/karimknaebel/turm/commit/37e205aaf819e99e13aea70327de84289cba0482))\n* scroll to top/bottom ([0022a70](https://github.com/karimknaebel/turm/commit/0022a70a58d6a0f2b1e159f0b5afef99ae6ea2c1))\n\n## [0.3.0](https://github.com/karimknaebel/turm/compare/v0.2.0...v0.3.0) (2023-04-17)\n\n\n### Features\n\n* add shell completions ([e9b8de0](https://github.com/karimknaebel/turm/commit/e9b8de0018b3dd91d72db6e3c164aa18a1fe17d9))\n* proper cli with help ([90988f6](https://github.com/karimknaebel/turm/commit/90988f65208b353204acd6a570be45e753bfcdfc))\n\n## [0.2.0](https://github.com/karimknaebel/turm/compare/v0.1.0...v0.2.0) (2023-04-15)\n\n\n### Features\n\n* cancel jobs ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b))\n* select first job on launch ([7c742fd](https://github.com/karimknaebel/turm/commit/7c742fdd3b66787b10df6a017de6c7522c8f9858))\n\n\n### Bug Fixes\n\n* clear the log on empty selection ([518afdb](https://github.com/karimknaebel/turm/commit/518afdbf67ada9ea1d7b2597765630cba8a00ee4))\n* correctly display job ids in arrays ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b))\n\n## 0.1.0 (2023-03-31)\n\n\n### Features\n\n* accept same cli args as `squeue` ([1f1a5ac](https://github.com/karimknaebel/turm/commit/1f1a5ac8f0b92b435b09e09981c95cbb00290a20))\n* add cargo metadata ([78487bb](https://github.com/karimknaebel/turm/commit/78487bbe93c8c1efaef8b218e72c68a4dbe3c67a))\n* better error handling ([ad47d19](https://github.com/karimknaebel/turm/commit/ad47d19ad6abccb80bc7d5c9ac3faf44ca03a92a))\n* better layout ([67e24e0](https://github.com/karimknaebel/turm/commit/67e24e078df0eed492123e498282942400cbbcf9))\n* config interval file ([7e6678d](https://github.com/karimknaebel/turm/commit/7e6678d834ce5535dfe2ede8e88974ccbf36c453))\n* fast scroll ([8df9158](https://github.com/karimknaebel/turm/commit/8df91589f8ef6c3cd403faecfc40142fd238d0a4))\n* faster log file loading ([9f954cc](https://github.com/karimknaebel/turm/commit/9f954ccff53fc7ffdb4412d1a490ef012bf4cc95))\n* faster log loading ([b4b0fa4](https://github.com/karimknaebel/turm/commit/b4b0fa4df97d51976f2cadffd527a07fd3804346))\n* help bar ([ab63a9e](https://github.com/karimknaebel/turm/commit/ab63a9e2cd9b2ea05a8d45789b8dfb04d580c932))\n* partial reads (like tail -f) ([86f04af](https://github.com/karimknaebel/turm/commit/86f04af1bf78783c37c4cecbef4d3292280f4f5e))\n* prettier ([c70de5e](https://github.com/karimknaebel/turm/commit/c70de5ea4f412531c203bb308ee769e6cc861828))\n* scroll to bottom with end ([01423f1](https://github.com/karimknaebel/turm/commit/01423f1a8c5da16f97dc01efd4e73cbb96d8c810))\n* show job details ([904ff7c](https://github.com/karimknaebel/turm/commit/904ff7cef52e8971f7c6146ec217065988001336))\n* show state and reason in details panel ([a77d4a3](https://github.com/karimknaebel/turm/commit/a77d4a3ff7d823f89ea33921dee28aa9ff7b6a3f))\n* show state in list ([823a0a2](https://github.com/karimknaebel/turm/commit/823a0a263bc33b7a1e77d92601820059dfc22a14))\n\n\n### Bug Fixes\n\n* error on shutdown ([ff516ca](https://github.com/karimknaebel/turm/commit/ff516cac734fcd06a443122aca408d228046484a))\n* hide incomplete lines in log files ([28eb452](https://github.com/karimknaebel/turm/commit/28eb452f9b4e8900d74be491368787bbe2197fc1))\n* log title ([d42f79a](https://github.com/karimknaebel/turm/commit/d42f79ae7dcfec4d33d29fdcc48e1e986d1ea8b9))\n* warnings ([ffd1211](https://github.com/karimknaebel/turm/commit/ffd1211228490960186d9cf8dc1d773a38558b16))\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"turm\"\nversion = \"0.14.0\"\nauthors = [\"Karim Knaebel <contact@knaebel.dev>\"]\ndescription = \"A TUI for the Slurm Workload Manager.\"\nrepository = \"https://github.com/karimknaebel/turm\"\nlicense = \"MIT\"\nedition = \"2024\"\nrust-version = \"1.87\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nclap = { version = \"4.5.40\", features = [\"derive\"] }\nclap_complete = \"4.5.54\"\ncrossbeam = \"0.8.4\"\ncrossterm = \"0.29.0\"\nitertools = \"0.14.0\"\nlazy_static = \"1.5.0\"\nnotify = \"8.0.0\"\nratatui = \"0.30.0\"\nregex = \"1.11.1\"\ntui-input = { version = \"0.14.0\", default-features = false, features = [\"crossterm\"] }\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Karim Knaebel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# turm\n\n[![image](https://img.shields.io/pypi/v/turm.svg)](https://pypi.python.org/pypi/turm)\n[![image](https://img.shields.io/crates/v/turm.svg)](https://crates.io/crates/turm)\n[![Conda Version](https://img.shields.io/conda/vn/conda-forge/turm.svg)](https://anaconda.org/conda-forge/turm)\n\nA TUI for [Slurm](https://slurm.schedmd.com/), which provides a convenient way to manage your cluster jobs.\n\n<img alt=\"turm demo\" src=\"https://github.com/user-attachments/assets/7daade50-def3-4bf8-bf12-df311438094e\" width=\"100%\" />\n\n`turm` accepts the same options as `squeue` (see [man squeue](https://slurm.schedmd.com/squeue.html#SECTION_OPTIONS)). Use `turm --help` to get a list of all available options. For example, to show only your own jobs, sorted by descending job ID, including all job states (i.e., including completed and failed jobs):\n```shell\nturm --me --sort=-id --states=ALL\n```\n\n## Installation\n\n`turm` is available on [PyPI](https://pypi.org/project/turm/), [crates.io](https://crates.io/crates/turm), and [conda-forge](https://github.com/conda-forge/turm-feedstock):\n\n```shell\n# With uv.\nuv tool install turm\n\n# With pip.\npip install turm\n\n# With cargo.\ncargo install turm\n\n# With pixi.\npixi global install turm\n\n# With conda.\nconda install --channel conda-forge turm\n\n# With wget. Make sure ~/.local/bin is in your $PATH.\nwget https://github.com/karimknaebel/turm/releases/latest/download/turm-x86_64-unknown-linux-musl.tar.gz -O - | tar -xz -C ~/.local/bin/\n```\n\nThe [release page](https://github.com/karimknaebel/turm/releases) also contains precompiled binaries for Linux.\n\n### Shell Completion (optional)\n\n#### Bash\n\nIn your `.bashrc`, add the following line:\n```bash\neval \"$(turm completion bash)\"\n```\n\n#### Zsh\n\nIn your `.zshrc`, add the following line:\n```zsh\neval \"$(turm completion zsh)\"\n```\n\n#### Fish\n\nIn your `config.fish` or in a separate `completions/turm.fish` file, add the following line:\n```fish\nturm completion fish | source\n```\n\n## How it works\n\n`turm` obtains information about jobs by parsing the output of `squeue`.\nThe reason for this is that `squeue` is available on all Slurm clusters, and running it periodically is not too expensive for the Slurm controller ( particularly when [filtering by user](https://slurm.schedmd.com/squeue.html#OPT_user)).\nIn contrast, Slurm's C API is unstable, and Slurm's REST API is not always available and can be costly for the Slurm controller.\nAnother advantage is that we get free support for the exact same CLI flags as `squeue`, which users are already familiar with, for filtering and sorting the jobs.\n\n### Resource usage\n\nTL;DR: `turm` ≈ `watch -n2 squeue` + `tail -f slurm-log.out`\n\nSpecial care has been taken to ensure that `turm` is as lightweight as possible in terms of its impact on the Slurm controller and its file I/O operations.\nThe job queue is updated every two seconds by running `squeue`.\nWhen there are many jobs in the queue, it is advisable to specify a single user to reduce the load on the Slurm controller (see [squeue --user](https://slurm.schedmd.com/squeue.html#OPT_user)).\n`turm` updates the currently displayed log file on every inotify modify notification, and it only reads the newly appended lines after the initial read.\nHowever, since inotify notifications are not supported for remote file systems, such as NFS, `turm` also polls the file for newly appended bytes every two seconds.\n\n## Development without Slurm\n\nFor local UI testing, this repository includes mocks for `squeue`, `scancel`, and `scontrol`:\n\n```shell\nPATH=scripts/mock-slurm/bin:$PATH cargo run -- --me\n```\n\nThe mock commands read/write files in `scripts/mock-slurm/logs`, so you can test log rendering and control actions without a Slurm install.\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=karimknaebel/turm&type=Date)](https://www.star-history.com/#karimknaebel/turm&Date)\n"
  },
  {
    "path": "scripts/mock-slurm/bin/scancel",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -- \"$script_dir/../../..\" && pwd)\"\nlog_dir=\"$root_dir/scripts/mock-slurm/logs\"\nlog_file=\"$log_dir/scancel.log\"\n\nmkdir -p \"$log_dir\"\n\ntimestamp=\"$(date '+%Y-%m-%d %H:%M:%S')\"\njob_id=\"unknown\"\nsignal=\"\"\n\nwhile (($#)); do\n    case \"$1\" in\n    --signal|-s)\n        signal=\"${2:-}\"\n        shift 2\n        ;;\n    --signal=*)\n        signal=\"${1#*=}\"\n        shift\n        ;;\n    --*)\n        shift\n        ;;\n    *)\n        job_id=\"$1\"\n        shift\n        ;;\n    esac\ndone\n\nif [[ -n \"$signal\" ]]; then\n    printf '[%s] canceled %s with signal %s\\n' \"$timestamp\" \"$job_id\" \"$signal\" >>\"$log_file\"\nelse\n    printf '[%s] canceled %s\\n' \"$timestamp\" \"$job_id\" >>\"$log_file\"\nfi\n"
  },
  {
    "path": "scripts/mock-slurm/bin/scontrol",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -- \"$script_dir/../../..\" && pwd)\"\nlog_dir=\"$root_dir/scripts/mock-slurm/logs\"\nlog_file=\"$log_dir/scontrol.log\"\n\nmkdir -p \"$log_dir\"\n\ntimestamp=\"$(date '+%Y-%m-%d %H:%M:%S')\"\njob_id=\"\"\ntime_limit=\"\"\n\nif [[ \"${1:-}\" == \"update\" ]]; then\n    shift\nfi\n\nfor arg in \"$@\"; do\n    case \"$arg\" in\n    JobId=*)\n        job_id=\"${arg#JobId=}\"\n        ;;\n    TimeLimit=*)\n        time_limit=\"${arg#TimeLimit=}\"\n        ;;\n    esac\ndone\n\nif [[ -n \"$job_id\" && -n \"$time_limit\" ]]; then\n    printf '[%s] updated %s TimeLimit=%s\\n' \"$timestamp\" \"$job_id\" \"$time_limit\" >>\"$log_file\"\n    exit 0\nfi\n\nprintf '[%s] invalid scontrol call: %s\\n' \"$timestamp\" \"$*\" >>\"$log_file\"\nexit 1\n"
  },
  {
    "path": "scripts/mock-slurm/bin/squeue",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -- \"$script_dir/../../..\" && pwd)\"\nlog_dir=\"$root_dir/scripts/mock-slurm/logs\"\n\nmkdir -p \"$log_dir\"\n\nstdout_running=\"$log_dir/job-1001.out\"\nstderr_running=\"$log_dir/job-1001.err\"\nstdout_pending=\"$log_dir/job-1002.out\"\nstderr_pending=\"$log_dir/job-1002.err\"\n\nif [ ! -f \"$stdout_running\" ]; then\n    cat >\"$stdout_running\" <<'EOF'\nStarting training loop...\nEpoch 1/50 - loss=1.8421\nEpoch 2/50 - loss=1.5033\nEOF\nfi\n\nif [ ! -f \"$stderr_running\" ]; then\n    : >\"$stderr_running\"\nfi\n\nsep='###turm###'\n\nprintf '%s\\n' \\\n\"1001${sep}train-model${sep}RUNNING${sep}${USER:-mock}${sep}00:12:37${sep}01:00:00${sep}N/A${sep}cpu=4,mem=8G${sep}debug${sep}mac-mini-01${sep}${stdout_running}${sep}${stderr_running}${sep}python train.py --epochs 50${sep}R${sep}None${sep}1001${sep}N/A${sep}mac-mini-01${sep}${root_dir}${sep}\" \\\n\"1002${sep}eval-suite${sep}PENDING${sep}${USER:-mock}${sep}0:00${sep}00:30:00${sep}2026-03-04T12:00:00${sep}cpu=2,mem=4G${sep}debug${sep}(null)${sep}${stdout_pending}${sep}${stderr_pending}${sep}python eval.py --dataset validation${sep}PD${sep}Resources${sep}1002${sep}N/A${sep}(null)${sep}${root_dir}${sep}\"\n"
  },
  {
    "path": "scripts/mock-slurm/logs/.gitkeep",
    "content": "\n"
  },
  {
    "path": "src/app.rs",
    "content": "use crossbeam::{\n    channel::{Receiver, TryRecvError, unbounded},\n    select,\n};\nuse itertools::Either;\nuse std::{cmp::min, iter::once, path::PathBuf, process::Command, time::Duration};\n\nuse crate::file_watcher::{FileWatcherError, FileWatcherHandle};\nuse crate::job_watcher::JobWatcherHandle;\n\nuse crossterm::event::{Event, KeyCode, KeyEvent, MouseButton, MouseEventKind};\nuse ratatui::{\n    Frame, Terminal,\n    backend::Backend,\n    layout::{Constraint, Direction, Layout, Rect},\n    style::{Color, Modifier, Style},\n    text::{Line, Span, Text},\n    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},\n};\nuse std::io;\nuse tui_input::{Input, backend::crossterm::EventHandler};\n\npub enum Focus {\n    Jobs,\n}\n\npub enum Dialog {\n    ConfirmCancelJob(String),\n    SelectCancelSignal { id: String, selected_signal: usize },\n    EditTimeLimit { id: String, input: Input },\n    CommandError { command: String, output: String },\n}\n\nstruct CommandFailure {\n    command: String,\n    output: String,\n}\n\n#[derive(Clone, Copy)]\npub enum ScrollAnchor {\n    Top,\n    Bottom,\n}\n\n#[derive(Default)]\npub enum OutputFileView {\n    #[default]\n    Stdout,\n    Stderr,\n}\n\npub struct App {\n    focus: Focus,\n    dialog: Option<Dialog>,\n    jobs: Vec<Job>,\n    job_list_state: ListState,\n    job_output: Result<String, FileWatcherError>,\n    job_output_anchor: ScrollAnchor,\n    job_output_offset: u16,\n    job_output_wrap: bool,\n    _job_watcher: JobWatcherHandle,\n    job_output_watcher: FileWatcherHandle,\n    // sender: Sender<AppMessage>,\n    receiver: Receiver<AppMessage>,\n    input_receiver: Receiver<std::io::Result<Event>>,\n    output_file_view: OutputFileView,\n    job_list_height: u16,\n    job_list_area: Rect,\n    job_output_area: Rect,\n    pending_input_event: Option<Event>,\n}\n\npub struct Job {\n    pub job_id: String,\n    pub array_id: String,\n    pub array_step: Option<String>,\n    pub name: String,\n    pub state: String,\n    pub state_compact: String,\n    pub reason: Option<String>,\n    pub user: String,\n    pub time: String,\n    pub time_limit: String,\n    pub start_time: String,\n    pub tres: String,\n    pub partition: String,\n    pub nodelist: String,\n    pub stdout: Option<PathBuf>,\n    pub stderr: Option<PathBuf>,\n    pub command: String,\n}\n\nimpl Job {\n    fn id(&self) -> String {\n        match self.array_step.as_ref() {\n            Some(array_step) => format!(\"{}_{}\", self.array_id, array_step),\n            None => self.job_id.clone(),\n        }\n    }\n}\n\npub enum AppMessage {\n    Jobs(Vec<Job>),\n    JobOutput(Result<String, FileWatcherError>),\n    Key(KeyEvent),\n    MouseClick(usize),\n    MouseWheel {\n        target: MouseScrollTarget,\n        direction: MouseWheelDirection,\n        amount: u16,\n    },\n}\n\n#[derive(Clone, Copy, PartialEq, Eq)]\npub(crate) enum MouseWheelDirection {\n    Up,\n    Down,\n}\n\n#[derive(Clone, Copy, PartialEq, Eq)]\npub(crate) enum MouseScrollTarget {\n    Jobs,\n    Output,\n}\n\nconst SCANCEL_SIGNALS: &[&str] = &[\"TERM\", \"INT\", \"HUP\", \"USR1\", \"USR2\", \"STOP\", \"CONT\", \"KILL\"];\nconst DIALOG_WIDTH: u16 = 80;\n\nimpl App {\n    pub fn new(\n        input_receiver: Receiver<std::io::Result<Event>>,\n        slurm_refresh_rate: u64,\n        file_refresh_rate: u64,\n        squeue_args: Vec<String>,\n    ) -> App {\n        let (sender, receiver) = unbounded();\n        Self {\n            focus: Focus::Jobs,\n            dialog: None,\n            jobs: Vec::new(),\n            _job_watcher: JobWatcherHandle::new(\n                sender.clone(),\n                Duration::from_secs(slurm_refresh_rate),\n                squeue_args,\n            ),\n            job_list_state: ListState::default(),\n            job_output: Ok(\"\".to_string()),\n            job_output_anchor: ScrollAnchor::Bottom,\n            job_output_offset: 0,\n            job_output_wrap: false,\n            job_output_watcher: FileWatcherHandle::new(\n                sender.clone(),\n                Duration::from_secs(file_refresh_rate),\n            ),\n            // sender,\n            receiver,\n            input_receiver,\n            output_file_view: OutputFileView::default(),\n            job_list_height: 0,\n            job_list_area: Rect::default(),\n            job_output_area: Rect::default(),\n            pending_input_event: None,\n        }\n    }\n}\n\nimpl App {\n    pub fn run<B: Backend<Error = io::Error>>(\n        &mut self,\n        terminal: &mut Terminal<B>,\n    ) -> io::Result<()> {\n        terminal.draw(|f| self.ui(f))?;\n\n        loop {\n            let (should_quit, should_draw) = if let Some(event) = self.pending_input_event.take() {\n                self.handle_input_event(event)\n            } else {\n                select! {\n                    recv(self.receiver) -> event => {\n                        self.handle(event.unwrap());\n                        (false, true)\n                    }\n                    recv(self.input_receiver) -> input_res => {\n                        self.handle_input_event(input_res.unwrap().unwrap())\n                    }\n                }\n            };\n            if should_quit {\n                return Ok(());\n            }\n\n            if should_draw {\n                terminal.draw(|f| self.ui(f))?;\n            }\n        }\n    }\n\n    fn try_recv_input_event(&mut self) -> Option<Event> {\n        if let Some(event) = self.pending_input_event.take() {\n            return Some(event);\n        }\n\n        loop {\n            match self.input_receiver.try_recv() {\n                Ok(Ok(event)) => return Some(event),\n                Ok(Err(_)) => continue,\n                Err(TryRecvError::Empty | TryRecvError::Disconnected) => return None,\n            }\n        }\n    }\n\n    fn handle_input_event(&mut self, event: Event) -> (bool, bool) {\n        match event {\n            Event::Key(key) => {\n                if key.code == KeyCode::Char('q') {\n                    return (true, false);\n                }\n                self.handle(AppMessage::Key(key));\n                (false, true)\n            }\n            Event::Paste(_) => (false, false),\n            Event::Mouse(mouse) => match mouse.kind {\n                MouseEventKind::Down(MouseButton::Left) => {\n                    if self.dialog.is_some() {\n                        return (false, false);\n                    }\n                    if let Some(index) = self.job_index_at(mouse.column, mouse.row) {\n                        if self.job_list_state.selected() != Some(index) {\n                            self.handle(AppMessage::MouseClick(index));\n                            return (false, true);\n                        }\n                    }\n                    (false, false)\n                }\n                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {\n                    if self.dialog.is_some() {\n                        return (false, false);\n                    }\n                    let Some(target) = self.mouse_scroll_target(mouse.column, mouse.row) else {\n                        return (false, false);\n                    };\n                    let direction = mouse_wheel_direction(mouse.kind).unwrap();\n                    let mut amount = 1u16;\n                    while let Some(next_event) = self.try_recv_input_event() {\n                        let should_merge = if let Event::Mouse(next_mouse) = &next_event {\n                            mouse_wheel_direction(next_mouse.kind) == Some(direction)\n                                && self.mouse_scroll_target(next_mouse.column, next_mouse.row)\n                                    == Some(target)\n                        } else {\n                            false\n                        };\n                        if should_merge {\n                            amount = amount.saturating_add(1);\n                        } else {\n                            self.pending_input_event = Some(next_event);\n                            break;\n                        }\n                    }\n                    self.handle(AppMessage::MouseWheel {\n                        target,\n                        direction,\n                        amount,\n                    });\n                    (false, true)\n                }\n                _ => (false, false),\n            },\n            Event::Resize(_, _) => (false, true),\n            _ => (false, false),\n        }\n    }\n\n    fn mouse_scroll_target(&self, column: u16, row: u16) -> Option<MouseScrollTarget> {\n        if rect_contains(self.job_list_area, column, row) {\n            Some(MouseScrollTarget::Jobs)\n        } else if rect_contains(self.job_output_area, column, row) {\n            Some(MouseScrollTarget::Output)\n        } else {\n            None\n        }\n    }\n\n    fn handle(&mut self, msg: AppMessage) {\n        match msg {\n            AppMessage::Jobs(jobs) => {\n                // On refresh: keep the same job selected if it still exists\n                let old_index = self.job_list_state.selected();\n                let old_id = old_index.and_then(|i| self.jobs.get(i)).map(|j| j.id());\n\n                self.jobs = jobs;\n\n                if self.jobs.is_empty() {\n                    self.job_list_state.select(None);\n                } else if let Some(id) = old_id {\n                    let new_index = self\n                        .jobs\n                        .iter()\n                        .position(|j| j.id() == id)\n                        .unwrap_or(old_index.unwrap_or(0).min(self.jobs.len() - 1));\n                    self.job_list_state.select(Some(new_index));\n                } else {\n                    self.job_list_state.select_first();\n                }\n            }\n            AppMessage::JobOutput(content) => self.job_output = content,\n            AppMessage::Key(key) => {\n                if self.dialog.is_some() {\n                    let mut close_dialog = false;\n                    let mut scancel_request = None;\n                    let mut timelimit_request = None;\n                    let mut command_failure = None;\n\n                    match self.dialog.as_mut().expect(\"dialog must exist\") {\n                        Dialog::ConfirmCancelJob(id) => match key.code {\n                            KeyCode::Enter | KeyCode::Char('y') => {\n                                scancel_request = Some((id.clone(), None));\n                                close_dialog = true;\n                            }\n                            KeyCode::Esc => {\n                                close_dialog = true;\n                            }\n                            _ => {}\n                        },\n                        Dialog::SelectCancelSignal {\n                            id,\n                            selected_signal,\n                        } => match key.code {\n                            KeyCode::Up | KeyCode::Char('k') => {\n                                *selected_signal = selected_signal.saturating_sub(1);\n                            }\n                            KeyCode::Down | KeyCode::Char('j') => {\n                                *selected_signal = min(\n                                    selected_signal.saturating_add(1),\n                                    SCANCEL_SIGNALS.len().saturating_sub(1),\n                                );\n                            }\n                            KeyCode::Enter => {\n                                scancel_request =\n                                    Some((id.clone(), Some(SCANCEL_SIGNALS[*selected_signal])));\n                                close_dialog = true;\n                            }\n                            KeyCode::Esc => {\n                                close_dialog = true;\n                            }\n                            KeyCode::Char(c) if c.is_ascii_digit() => {\n                                if let Some(index) = signal_index_for_digit(c) {\n                                    if index < SCANCEL_SIGNALS.len() {\n                                        *selected_signal = index;\n                                    }\n                                }\n                            }\n                            _ => {}\n                        },\n                        Dialog::EditTimeLimit { id, input } => match key.code {\n                            KeyCode::Enter => {\n                                if let Some(time_limit) = validated_time_limit(input) {\n                                    timelimit_request = Some((id.clone(), time_limit));\n                                    close_dialog = true;\n                                }\n                            }\n                            KeyCode::Esc => {\n                                close_dialog = true;\n                            }\n                            _ => {\n                                input.handle_event(&Event::Key(key));\n                            }\n                        },\n                        Dialog::CommandError { .. } => match key.code {\n                            KeyCode::Enter | KeyCode::Esc => {\n                                close_dialog = true;\n                            }\n                            _ => {}\n                        },\n                    };\n\n                    if let Some((id, signal)) = scancel_request {\n                        command_failure = execute_scancel(&id, signal).err();\n                    }\n                    if let Some((id, time_limit)) = timelimit_request {\n                        command_failure = execute_scontrol_update_timelimit(&id, &time_limit).err();\n                    }\n                    if let Some(CommandFailure { command, output }) = command_failure {\n                        self.dialog = Some(Dialog::CommandError { command, output });\n                    } else if close_dialog {\n                        self.dialog = None;\n                    }\n                } else {\n                    match key.code {\n                        KeyCode::Char('h') | KeyCode::Left => self.focus_previous_panel(),\n                        KeyCode::Char('l') | KeyCode::Right => self.focus_next_panel(),\n                        KeyCode::Char('k') | KeyCode::Up => match self.focus {\n                            Focus::Jobs => self.select_previous_job(),\n                        },\n                        KeyCode::Char('j') | KeyCode::Down => match self.focus {\n                            Focus::Jobs => self.select_next_job(),\n                        },\n                        KeyCode::Char('g') => match self.focus {\n                            Focus::Jobs => self.select_first_job(),\n                        },\n                        KeyCode::Char('G') => match self.focus {\n                            Focus::Jobs => self.select_last_job(),\n                        },\n                        KeyCode::Char('u') => match self.focus {\n                            Focus::Jobs => {\n                                if key\n                                    .modifiers\n                                    .contains(crossterm::event::KeyModifiers::CONTROL)\n                                {\n                                    self.scroll_jobs_half_page_up()\n                                }\n                            }\n                        },\n                        KeyCode::Char('d') => match self.focus {\n                            Focus::Jobs => {\n                                if key\n                                    .modifiers\n                                    .contains(crossterm::event::KeyModifiers::CONTROL)\n                                {\n                                    self.scroll_jobs_half_page_down()\n                                }\n                            }\n                        },\n                        KeyCode::PageDown => {\n                            let delta = if key.modifiers.intersects(\n                                crossterm::event::KeyModifiers::SHIFT\n                                    | crossterm::event::KeyModifiers::CONTROL\n                                    | crossterm::event::KeyModifiers::ALT,\n                            ) {\n                                50\n                            } else {\n                                1\n                            };\n                            self.scroll_job_output_down_by(delta);\n                        }\n                        KeyCode::PageUp => {\n                            let delta = if key.modifiers.intersects(\n                                crossterm::event::KeyModifiers::SHIFT\n                                    | crossterm::event::KeyModifiers::CONTROL\n                                    | crossterm::event::KeyModifiers::ALT,\n                            ) {\n                                50\n                            } else {\n                                1\n                            };\n                            self.scroll_job_output_up_by(delta);\n                        }\n                        KeyCode::Home => {\n                            self.job_output_offset = 0;\n                            self.job_output_anchor = ScrollAnchor::Top;\n                        }\n                        KeyCode::End => {\n                            self.job_output_offset = 0;\n                            self.job_output_anchor = ScrollAnchor::Bottom;\n                        }\n                        KeyCode::Char('c') => {\n                            if let Some(id) = self.selected_job_id() {\n                                self.dialog = Some(Dialog::ConfirmCancelJob(id));\n                            }\n                        }\n                        KeyCode::Char('C') => {\n                            if let Some(id) = self.selected_job_id() {\n                                self.dialog = Some(Dialog::SelectCancelSignal {\n                                    id,\n                                    selected_signal: 0,\n                                });\n                            }\n                        }\n                        KeyCode::Char('t') => {\n                            if let Some(job) = self.selected_job() {\n                                self.dialog = Some(Dialog::EditTimeLimit {\n                                    id: job.id(),\n                                    input: Input::new(job.time_limit.clone()),\n                                });\n                            }\n                        }\n                        KeyCode::Char('o') => {\n                            self.output_file_view = match self.output_file_view {\n                                OutputFileView::Stdout => OutputFileView::Stderr,\n                                OutputFileView::Stderr => OutputFileView::Stdout,\n                            };\n                        }\n                        KeyCode::Char('w') => {\n                            self.job_output_wrap = !self.job_output_wrap;\n                        }\n                        _ => {}\n                    };\n                }\n            }\n            AppMessage::MouseClick(index) => {\n                if self.dialog.is_none() && index < self.jobs.len() {\n                    self.job_list_state.select(Some(index));\n                }\n            }\n            AppMessage::MouseWheel {\n                target,\n                direction,\n                amount,\n            } => {\n                if self.dialog.is_none() {\n                    match target {\n                        MouseScrollTarget::Jobs => match direction {\n                            MouseWheelDirection::Up => self.job_list_state.scroll_up_by(amount),\n                            MouseWheelDirection::Down => self.job_list_state.scroll_down_by(amount),\n                        },\n                        MouseScrollTarget::Output => match direction {\n                            MouseWheelDirection::Up => self.scroll_job_output_up_by(amount),\n                            MouseWheelDirection::Down => self.scroll_job_output_down_by(amount),\n                        },\n                    }\n                }\n            }\n        }\n\n        // update\n        self.job_output_watcher\n            .set_file_path(self.job_list_state.selected().and_then(|i| {\n                self.jobs.get(i).and_then(|j| match self.output_file_view {\n                    OutputFileView::Stdout => j.stdout.clone(),\n                    OutputFileView::Stderr => j.stderr.clone(),\n                })\n            }));\n    }\n\n    fn ui(&mut self, f: &mut Frame) {\n        // Layout\n\n        let content_help = Layout::default()\n            .direction(Direction::Vertical)\n            .constraints([Constraint::Min(3), Constraint::Length(1)].as_ref())\n            .split(f.area());\n\n        let master_detail = Layout::default()\n            .direction(Direction::Horizontal)\n            .constraints([Constraint::Min(50), Constraint::Percentage(70)].as_ref())\n            .split(content_help[0]);\n\n        let job_detail_log = Layout::default()\n            .direction(Direction::Vertical)\n            .constraints([Constraint::Length(8), Constraint::Min(3)].as_ref())\n            .split(master_detail[1]);\n\n        // Help\n        let help_options = vec![\n            (\"q\", \"quit\"),\n            (\"⏶/⏷\", \"navigate\"),\n            (\"pgup/pgdown\", \"scroll\"),\n            (\"home/end\", \"top/bottom\"),\n            (\"esc\", \"cancel\"),\n            (\"enter\", \"confirm\"),\n            (\"c/C\", \"cancel/signal\"),\n            (\"t\", \"set time limit\"),\n            (\"o\", \"toggle stdout/stderr\"),\n            (\"w\", \"toggle text wrap\"),\n        ];\n        let blue_style = Style::default().fg(Color::Blue);\n        let light_blue_style = Style::default().fg(Color::LightBlue);\n\n        let help = Line::from(help_options.iter().fold(\n            Vec::new(),\n            |mut acc, (key, description)| {\n                if !acc.is_empty() {\n                    acc.push(Span::raw(\" | \"));\n                }\n                acc.push(Span::styled(*key, blue_style));\n                acc.push(Span::raw(\": \"));\n                acc.push(Span::styled(*description, light_blue_style));\n                acc\n            },\n        ));\n\n        let help = Paragraph::new(help);\n        f.render_widget(help, content_help[1]);\n\n        // Jobs\n        let max_id_len = self.jobs.iter().map(|j| j.id().len()).max().unwrap_or(0);\n        let max_user_len = self.jobs.iter().map(|j| j.user.len()).max().unwrap_or(0);\n        let max_partition_len = self\n            .jobs\n            .iter()\n            .map(|j| j.partition.len())\n            .max()\n            .unwrap_or(0);\n        let max_time_len = self.jobs.iter().map(|j| j.time.len()).max().unwrap_or(0);\n        let max_state_compact_len = self\n            .jobs\n            .iter()\n            .map(|j| j.state_compact.len())\n            .max()\n            .unwrap_or(0);\n        let jobs: Vec<ListItem> = self\n            .jobs\n            .iter()\n            .map(|j| {\n                ListItem::new(Line::from(vec![\n                    Span::styled(\n                        format!(\n                            \"{:<max$.max$}\",\n                            j.state_compact,\n                            max = max_state_compact_len\n                        ),\n                        Style::default(),\n                    ),\n                    Span::raw(\" \"),\n                    Span::styled(\n                        format!(\"{:<max$.max$}\", j.id(), max = max_id_len),\n                        Style::default().fg(Color::Yellow),\n                    ),\n                    Span::raw(\" \"),\n                    Span::styled(\n                        format!(\"{:<max$.max$}\", j.partition, max = max_partition_len),\n                        Style::default().fg(Color::Blue),\n                    ),\n                    Span::raw(\" \"),\n                    Span::styled(\n                        format!(\"{:<max$.max$}\", j.user, max = max_user_len),\n                        Style::default().fg(Color::Green),\n                    ),\n                    Span::raw(\" \"),\n                    Span::styled(\n                        format!(\"{:>max$.max$}\", j.time, max = max_time_len),\n                        Style::default().fg(Color::Red),\n                    ),\n                    Span::raw(\" \"),\n                    Span::raw(&j.name),\n                ]))\n            })\n            .collect();\n        let job_list = List::new(jobs)\n            .block(\n                Block::default()\n                    .title(format!(\"─Jobs ({})\", self.jobs.len()))\n                    .borders(Borders::ALL)\n                    .border_type(BorderType::Rounded)\n                    .border_style(if self.dialog.is_some() {\n                        Style::default()\n                    } else {\n                        match self.focus {\n                            Focus::Jobs => Style::default().fg(Color::Green),\n                        }\n                    }),\n            )\n            .highlight_style(Style::default().bg(Color::Green).fg(Color::Black));\n        f.render_stateful_widget(job_list, master_detail[0], &mut self.job_list_state);\n        self.job_list_height = master_detail[0].height.saturating_sub(2); // account for borders\n        self.job_list_area = master_detail[0];\n\n        // Job details\n\n        let job_detail = self\n            .job_list_state\n            .selected()\n            .and_then(|i| self.jobs.get(i));\n\n        let job_detail = job_detail.map(|j| {\n            let mut state_spans = vec![\n                Span::styled(\"State  \", Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(&j.state),\n            ];\n            if j.state == \"PENDING\" {\n                state_spans.extend([\n                    Span::styled(\" Start \", Style::default().fg(Color::Yellow)),\n                    Span::raw(&j.start_time),\n                ]);\n            }\n            if let Some(s) = j.reason.as_deref() {\n                state_spans.extend([\n                    Span::styled(\" Reason \", Style::default().fg(Color::Yellow)),\n                    Span::raw(s),\n                ]);\n            }\n            let state = Line::from(state_spans);\n            let name = Line::from(vec![\n                Span::styled(\"Name   \", Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(&j.name),\n            ]);\n            let command = Line::from(vec![\n                Span::styled(\"Command\", Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(&j.command),\n            ]);\n            let nodes = Line::from(vec![\n                Span::styled(\"Nodes  \", Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(&j.nodelist),\n            ]);\n            let tres = Line::from(vec![\n                Span::styled(\"TRES   \", Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(&j.tres),\n            ]);\n            let ui_stdout_text = match self.output_file_view {\n                OutputFileView::Stdout => \"stdout \",\n                OutputFileView::Stderr => \"stderr \",\n            };\n            let stdout = Line::from(vec![\n                Span::styled(ui_stdout_text, Style::default().fg(Color::Yellow)),\n                Span::raw(\" \"),\n                Span::raw(\n                    match self.output_file_view {\n                        OutputFileView::Stdout => &j.stdout,\n                        OutputFileView::Stderr => &j.stderr,\n                    }\n                    .as_ref()\n                    .map(|p| p.to_str().unwrap_or_default())\n                    .unwrap_or_default(),\n                ),\n            ]);\n\n            Text::from(vec![state, name, command, nodes, tres, stdout])\n        });\n        let job_detail = Paragraph::new(job_detail.unwrap_or_default()).block(\n            Block::default()\n                .title(\"─Details\")\n                .borders(Borders::ALL)\n                .border_type(BorderType::Rounded),\n        );\n        f.render_widget(job_detail, job_detail_log[0]);\n\n        // Log\n        let log_area = job_detail_log[1];\n        self.job_output_area = log_area;\n        let log_title = Line::from(vec![\n            Span::raw(\"─\"),\n            Span::raw(match self.output_file_view {\n                OutputFileView::Stdout => \"stdout\",\n                OutputFileView::Stderr => \"stderr\",\n            }),\n            Span::styled(\n                match self.job_output_anchor {\n                    ScrollAnchor::Top if self.job_output_offset == 0 => \"[T]\".to_string(),\n                    ScrollAnchor::Top => format!(\"[T+{}]\", self.job_output_offset),\n                    ScrollAnchor::Bottom if self.job_output_offset == 0 => \"\".to_string(),\n                    ScrollAnchor::Bottom => format!(\"[B-{}]\", self.job_output_offset),\n                },\n                Style::default().add_modifier(Modifier::DIM),\n            ),\n        ]);\n        let log_block = Block::default().title(log_title).borders(Borders::ALL);\n        let log_block = log_block.border_type(BorderType::Rounded);\n\n        // let job_log = self.job_stdout.as_deref().map(|s| {\n        //     string_for_paragraph(\n        //         s,\n        //         log_block.inner(log_area).height as usize,\n        //         log_block.inner(log_area).width as usize,\n        //         self.job_stdout_offset as usize,\n        //     )\n        // }).unwrap_or_else(|e| {\n        //     self.job_stdout_offset = 0;\n        //     \"\".to_string()\n        // });\n\n        let log = match self.job_output.as_deref() {\n            Ok(s) => Paragraph::new(fit_text(\n                s,\n                log_block.inner(log_area).height as usize,\n                log_block.inner(log_area).width as usize,\n                self.job_output_anchor,\n                self.job_output_offset as usize,\n                self.job_output_wrap,\n            )),\n            Err(e) => Paragraph::new(e.to_string())\n                .style(Style::default().fg(Color::Red))\n                .wrap(Wrap { trim: true }),\n        }\n        .block(log_block);\n\n        f.render_widget(log, log_area);\n\n        if let Some(dialog) = &self.dialog {\n            fn centered_dialog_area(width: u16, lines: u16, viewport: Rect) -> Rect {\n                let dialog_width = min(width, viewport.width);\n                let dialog_height = min(lines, viewport.height);\n                let dialog_x = viewport.x + viewport.width.saturating_sub(dialog_width) / 2;\n                let dialog_y = viewport.y + viewport.height.saturating_sub(dialog_height) / 2;\n\n                Rect::new(dialog_x, dialog_y, dialog_width, dialog_height)\n            }\n\n            match dialog {\n                Dialog::ConfirmCancelJob(id) => {\n                    let dialog = Paragraph::new(Line::from(vec![\n                        Span::raw(\"Cancel job \"),\n                        Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),\n                        Span::raw(\"?\"),\n                    ]))\n                    .style(Style::default().fg(Color::White))\n                    .wrap(Wrap { trim: true })\n                    .block(\n                        Block::default()\n                            .title(\"─Cancel\")\n                            .borders(Borders::ALL)\n                            .border_type(BorderType::Rounded)\n                            .style(Style::default().fg(Color::Green)),\n                    );\n\n                    let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area());\n                    f.render_widget(Clear, area);\n                    f.render_widget(dialog, area);\n                }\n                Dialog::SelectCancelSignal {\n                    id,\n                    selected_signal,\n                } => {\n                    let mut rows = vec![\n                        Line::from(vec![\n                            Span::raw(\"Send signal to job \"),\n                            Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),\n                            Span::raw(\":\"),\n                        ]),\n                        Line::default(),\n                    ];\n                    rows.extend(SCANCEL_SIGNALS.iter().enumerate().map(|(i, signal)| {\n                        let signal_style = if i == *selected_signal {\n                            Style::default().fg(Color::Black).bg(Color::Green)\n                        } else {\n                            Style::default()\n                        };\n                        let shortcut_style = signal_style.add_modifier(Modifier::DIM);\n                        Line::from(vec![\n                            Span::styled(format!(\"{}. \", i + 1), shortcut_style),\n                            Span::styled(*signal, signal_style),\n                        ])\n                    }));\n\n                    let dialog = Paragraph::new(Text::from(rows))\n                        .style(Style::default().fg(Color::White))\n                        .wrap(Wrap { trim: true })\n                        .block(\n                            Block::default()\n                                .title(\"─Signal\")\n                                .borders(Borders::ALL)\n                                .border_type(BorderType::Rounded)\n                                .style(Style::default().fg(Color::Green)),\n                        );\n\n                    let area = centered_dialog_area(\n                        DIALOG_WIDTH,\n                        SCANCEL_SIGNALS.len() as u16 + 4,\n                        f.area(),\n                    );\n                    f.render_widget(Clear, area);\n                    f.render_widget(dialog, area);\n                }\n                Dialog::EditTimeLimit { id, input } => {\n                    let block = Block::default()\n                        .title(\"─Time Limit\")\n                        .borders(Borders::ALL)\n                        .border_type(BorderType::Rounded)\n                        .style(Style::default().fg(Color::Green));\n\n                    let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area());\n                    let inner = block.inner(area);\n                    let prompt_prefix = \"Set time limit for job \";\n                    let prompt_suffix = \": \";\n                    let prompt_width = (prompt_prefix.chars().count()\n                        + id.chars().count()\n                        + prompt_suffix.chars().count())\n                        as u16;\n                    let available_width = inner.width.saturating_sub(prompt_width).max(1) as usize;\n                    let scroll = input.visual_scroll(available_width);\n                    let visible_value = input\n                        .value()\n                        .chars()\n                        .skip(scroll)\n                        .take(available_width)\n                        .collect::<String>();\n                    let dialog = Paragraph::new(Line::from(vec![\n                        Span::raw(prompt_prefix),\n                        Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),\n                        Span::raw(prompt_suffix),\n                        Span::styled(visible_value, Style::default().fg(Color::Blue)),\n                    ]))\n                    .style(Style::default().fg(Color::White))\n                    .block(block);\n\n                    f.render_widget(Clear, area);\n                    f.render_widget(dialog, area);\n\n                    let cursor_offset = input.visual_cursor().saturating_sub(scroll) as u16;\n                    let cursor_x = inner\n                        .x\n                        .saturating_add(prompt_width)\n                        .saturating_add(cursor_offset)\n                        .min(inner.x.saturating_add(inner.width.saturating_sub(1)));\n                    let cursor_y = inner.y;\n                    f.set_cursor_position((cursor_x, cursor_y));\n                }\n                Dialog::CommandError { command, output } => {\n                    let dialog_text = format!(\"Command: {command}\\n\\n{output}\");\n                    let lines = dialog_text\n                        .lines()\n                        .count()\n                        .saturating_add(2)\n                        .min(u16::MAX as usize) as u16;\n                    let dialog = Paragraph::new(dialog_text)\n                        .style(Style::default().fg(Color::White))\n                        .wrap(Wrap { trim: false })\n                        .block(\n                            Block::default()\n                                .title(\"─Command Error\")\n                                .borders(Borders::ALL)\n                                .border_type(BorderType::Rounded)\n                                .style(Style::default().fg(Color::Red)),\n                        );\n\n                    let area = centered_dialog_area(DIALOG_WIDTH, lines, f.area());\n                    f.render_widget(Clear, area);\n                    f.render_widget(dialog, area);\n                }\n            }\n        }\n    }\n}\n\nfn chunked_string(s: &str, first_chunk_size: usize, chunk_size: usize) -> Vec<&str> {\n    let stepped_indices = s\n        .char_indices()\n        .map(|(i, _)| i)\n        .enumerate()\n        .filter(|&(i, _)| {\n            if i > (first_chunk_size) {\n                chunk_size > 0 && (i - first_chunk_size).is_multiple_of(chunk_size)\n            } else {\n                i == 0 || i == first_chunk_size\n            }\n        })\n        .map(|(_, e)| e)\n        .collect::<Vec<_>>();\n    let windows = stepped_indices.windows(2).collect::<Vec<_>>();\n\n    let iter = windows.iter().map(|w| &s[w[0]..w[1]]);\n    let last_index = *stepped_indices.last().unwrap_or(&0);\n    iter.chain(once(&s[last_index..])).collect()\n}\n\nfn fit_text(\n    s: &'_ str,\n    lines: usize,\n    cols: usize,\n    anchor: ScrollAnchor,\n    offset: usize,\n    wrap: bool,\n) -> Text<'_> {\n    let s = s.rsplit_once(['\\r', '\\n']).map_or(s, |(p, _)| p); // skip everything after last line delimiter\n    let l = s.lines().flat_map(|l| l.split('\\r')); // bandaid for term escape codes\n    let iter = match anchor {\n        ScrollAnchor::Top => Either::Left(l),\n        ScrollAnchor::Bottom => Either::Right(l.rev()),\n    };\n    let iter = iter\n        .skip(offset)\n        .flat_map(|l| {\n            let iter = if wrap {\n                Either::Left(\n                    chunked_string(l, cols, cols.saturating_sub(2))\n                        .into_iter()\n                        .enumerate()\n                        .map(|(i, l)| {\n                            if i == 0 {\n                                Line::raw(l.chars().take(cols).collect::<String>())\n                            } else {\n                                Line::default().spans(vec![\n                                    Span::styled(\n                                        \"↪ \",\n                                        Style::default().add_modifier(Modifier::DIM),\n                                    ),\n                                    Span::raw(\n                                        l.chars().take(cols.saturating_sub(2)).collect::<String>(),\n                                    ),\n                                ])\n                            }\n                        }),\n                )\n            } else {\n                match l.chars().nth(cols) {\n                    Some(_) => {\n                        // has more chars than cols\n                        Either::Right(once(Line::default().spans(vec![\n                            Span::raw(l.chars().take(cols.saturating_sub(1)).collect::<String>()),\n                            Span::styled(\"…\", Style::default().add_modifier(Modifier::DIM)),\n                        ])))\n                    }\n                    None => {\n                        Either::Right(once(Line::raw(l.chars().take(cols).collect::<String>())))\n                    }\n                }\n            };\n            match anchor {\n                ScrollAnchor::Top => Either::Left(iter),\n                ScrollAnchor::Bottom => Either::Right(iter.rev()),\n            }\n        })\n        .take(lines);\n\n    match anchor {\n        ScrollAnchor::Top => Text::from(iter.collect::<Vec<_>>()),\n        ScrollAnchor::Bottom => Text::from(\n            iter.collect::<Vec<_>>()\n                .into_iter()\n                .rev()\n                .collect::<Vec<_>>(),\n        ),\n    }\n}\n\nimpl App {\n    fn selected_job(&self) -> Option<&Job> {\n        self.job_list_state\n            .selected()\n            .and_then(|i| self.jobs.get(i))\n    }\n\n    fn selected_job_id(&self) -> Option<String> {\n        self.selected_job().map(Job::id)\n    }\n\n    fn focus_next_panel(&mut self) {\n        match self.focus {\n            Focus::Jobs => self.focus = Focus::Jobs,\n        }\n    }\n\n    fn focus_previous_panel(&mut self) {\n        match self.focus {\n            Focus::Jobs => self.focus = Focus::Jobs,\n        }\n    }\n\n    fn select_next_job(&mut self) {\n        self.job_list_state.select_next();\n    }\n\n    fn select_previous_job(&mut self) {\n        self.job_list_state.select_previous();\n    }\n\n    fn select_first_job(&mut self) {\n        self.job_list_state.select_first();\n    }\n\n    fn select_last_job(&mut self) {\n        self.job_list_state.select_last();\n    }\n\n    fn scroll_jobs_half_page_down(&mut self) {\n        self.job_list_state.scroll_down_by(self.job_list_height / 2);\n    }\n\n    fn scroll_jobs_half_page_up(&mut self) {\n        self.job_list_state.scroll_up_by(self.job_list_height / 2);\n    }\n\n    fn job_index_at(&self, column: u16, row: u16) -> Option<usize> {\n        if self.jobs.is_empty() {\n            return None;\n        }\n        let inner = Rect::new(\n            self.job_list_area.x.saturating_add(1),\n            self.job_list_area.y.saturating_add(1),\n            self.job_list_area.width.saturating_sub(2),\n            self.job_list_area.height.saturating_sub(2),\n        );\n        if !rect_contains(inner, column, row) {\n            return None;\n        }\n\n        let row_in_list = (row - inner.y) as usize;\n        let index = self.job_list_state.offset().saturating_add(row_in_list);\n        (index < self.jobs.len()).then_some(index)\n    }\n\n    fn scroll_job_output_down_by(&mut self, delta: u16) {\n        match self.job_output_anchor {\n            ScrollAnchor::Top => {\n                self.job_output_offset = self.job_output_offset.saturating_add(delta)\n            }\n            ScrollAnchor::Bottom => {\n                self.job_output_offset = self.job_output_offset.saturating_sub(delta)\n            }\n        }\n    }\n\n    fn scroll_job_output_up_by(&mut self, delta: u16) {\n        match self.job_output_anchor {\n            ScrollAnchor::Top => {\n                self.job_output_offset = self.job_output_offset.saturating_sub(delta)\n            }\n            ScrollAnchor::Bottom => {\n                self.job_output_offset = self.job_output_offset.saturating_add(delta)\n            }\n        }\n    }\n}\n\nfn rect_contains(rect: Rect, column: u16, row: u16) -> bool {\n    column >= rect.x\n        && column < rect.x.saturating_add(rect.width)\n        && row >= rect.y\n        && row < rect.y.saturating_add(rect.height)\n}\n\nfn mouse_wheel_direction(kind: MouseEventKind) -> Option<MouseWheelDirection> {\n    match kind {\n        MouseEventKind::ScrollUp => Some(MouseWheelDirection::Up),\n        MouseEventKind::ScrollDown => Some(MouseWheelDirection::Down),\n        _ => None,\n    }\n}\n\nfn signal_index_for_digit(digit: char) -> Option<usize> {\n    let value = digit.to_digit(10)? as usize;\n    if value == 0 { None } else { Some(value - 1) }\n}\n\nfn validated_time_limit(input: &Input) -> Option<String> {\n    let time_limit = input.value().trim();\n    if time_limit.is_empty() {\n        None\n    } else {\n        Some(time_limit.to_string())\n    }\n}\n\nfn execute_scancel(job_id: &str, signal: Option<&str>) -> Result<(), CommandFailure> {\n    let mut command = Command::new(\"scancel\");\n    let mut command_display = String::from(\"scancel\");\n\n    if let Some(signal) = signal {\n        command.arg(\"--signal\").arg(signal);\n        command_display.push_str(&format!(\" --signal {signal}\"));\n    }\n    command.arg(job_id);\n    command_display.push_str(&format!(\" {job_id}\"));\n\n    execute_command(command, command_display)\n}\n\nfn execute_scontrol_update_timelimit(job_id: &str, time_limit: &str) -> Result<(), CommandFailure> {\n    let mut command = Command::new(\"scontrol\");\n    command\n        .arg(\"update\")\n        .arg(format!(\"JobId={job_id}\"))\n        .arg(format!(\"TimeLimit={time_limit}\"));\n\n    execute_command(\n        command,\n        format!(\"scontrol update JobId={job_id} TimeLimit={time_limit}\"),\n    )\n}\n\nfn execute_command(mut command: Command, command_label: String) -> Result<(), CommandFailure> {\n    let output = command.output().map_err(|error| CommandFailure {\n        command: command_label.clone(),\n        output: error.to_string(),\n    })?;\n\n    if output.status.success() {\n        return Ok(());\n    }\n\n    let mut details = vec![match output.status.code() {\n        Some(code) => format!(\"Exit code: {code}\"),\n        None => \"Exit code: N/A\".to_string(),\n    }];\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let stdout = stdout.trim_end();\n    let stderr = String::from_utf8_lossy(&output.stderr);\n    let stderr = stderr.trim_end();\n    let has_stdout = !stdout.is_empty();\n    let has_stderr = !stderr.is_empty();\n    match (has_stdout, has_stderr) {\n        (true, true) => {\n            details.push(format!(\"stdout:\\n{stdout}\"));\n            details.push(format!(\"stderr:\\n{stderr}\"));\n        }\n        (true, false) => {\n            details.push(stdout.to_string());\n        }\n        (false, true) => {\n            details.push(stderr.to_string());\n        }\n        (false, false) => {}\n    }\n\n    if details.len() == 1 {\n        details.push(\"No output.\".to_string());\n    }\n\n    Err(CommandFailure {\n        command: command_label,\n        output: details.join(\"\\n\\n\"),\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_chunked_string() {\n        // Divisible\n        let input = \"abcdefghij\";\n        let expected = vec![\"abcd\", \"ef\", \"gh\", \"ij\"];\n        assert_eq!(chunked_string(input, 4, 2), expected);\n\n        // Not divisible\n        let input = \"123456789\";\n        let expected = vec![\"1234\", \"56\", \"78\", \"9\"];\n        assert_eq!(chunked_string(input, 4, 2), expected);\n\n        // Smaller\n        let input = \"abc\";\n        let expected = vec![\"abc\"];\n        assert_eq!(chunked_string(input, 4, 2), expected);\n\n        // Smaller\n        let input = \"abcde\";\n        let expected = vec![\"abcd\", \"e\"];\n        assert_eq!(chunked_string(input, 4, 2), expected);\n\n        // Empty\n        let input = \"\";\n        let expected: Vec<&str> = vec![\"\"];\n        assert_eq!(chunked_string(input, 4, 2), expected);\n\n        let input = \"123456789\";\n        let expected = vec![\"1234\", \"56789\"];\n        assert_eq!(chunked_string(input, 4, 0), expected);\n\n        let input = \"123456789\";\n        let expected = vec![\"12\", \"34\", \"56\", \"78\", \"9\"];\n        assert_eq!(chunked_string(input, 0, 2), expected);\n\n        let input = \"123456789\";\n        let expected = vec![\"123456789\"];\n        assert_eq!(chunked_string(input, 0, 0), expected);\n    }\n\n    #[test]\n    fn test_validated_time_limit() {\n        assert_eq!(validated_time_limit(&Input::new(\"\".to_string())), None);\n        assert_eq!(validated_time_limit(&Input::new(\"   \".to_string())), None);\n        assert_eq!(\n            validated_time_limit(&Input::new(\" 01:00:00 \".to_string())),\n            Some(\"01:00:00\".to_string())\n        );\n    }\n}\n"
  },
  {
    "path": "src/file_watcher.rs",
    "content": "use std::{\n    fmt,\n    fs::File,\n    io::{self, Read, Seek},\n    path::{Path, PathBuf},\n    thread,\n    time::Duration,\n};\n\nuse crossbeam::{\n    channel::{Receiver, RecvError, SendError, Sender, unbounded},\n    select,\n};\nuse notify::{RecursiveMode, Watcher, event::ModifyKind};\n\nuse crate::app::AppMessage;\n\nstruct FileReader {\n    content_sender: Sender<io::Result<String>>,\n    receiver: Receiver<()>,\n    file_path: PathBuf,\n    interval: Duration,\n    content: String,\n    pos: u64,\n}\n\nstruct FileWatcher {\n    app: Sender<AppMessage>,\n    receiver: Receiver<FileWatcherMessage>,\n    file_path: Option<PathBuf>,\n    watching: bool, // Whether notify watch was successfully started for file_path\n    interval: Duration,\n}\npub enum FileWatcherMessage {\n    FilePath(Option<PathBuf>),\n}\n\npub struct FileWatcherHandle {\n    sender: Sender<FileWatcherMessage>,\n    file_path: Option<PathBuf>,\n}\n\npub enum FileWatcherError {\n    File(io::Error),\n}\n\nimpl fmt::Display for FileWatcherError {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        match self {\n            FileWatcherError::File(e) => write!(f, \"Read error: {}\", e),\n        }\n    }\n}\n\nimpl FileWatcher {\n    fn new(\n        app: Sender<AppMessage>,\n        receiver: Receiver<FileWatcherMessage>,\n        interval: Duration,\n    ) -> Self {\n        FileWatcher {\n            app,\n            receiver,\n            file_path: None,\n            watching: false,\n            interval,\n        }\n    }\n\n    fn run(&mut self) -> Result<(), RecvError> {\n        let (watch_sender, watch_receiver) = unbounded::<()>();\n        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {\n            let event = res.unwrap();\n            if let notify::EventKind::Modify(ModifyKind::Data(_)) = event.kind {\n                watch_sender.send(()).unwrap();\n            }\n        })\n        .unwrap();\n\n        let (mut _content_sender, mut _content_receiver) = unbounded::<io::Result<String>>();\n        let (mut _watch_sender, mut _watch_receiver) = unbounded::<()>();\n        loop {\n            select! {\n                recv(self.receiver) -> msg => {\n                    match msg? {\n                        FileWatcherMessage::FilePath(file_path) => {\n                            (_content_sender, _content_receiver) = unbounded();\n                            (_watch_sender, _watch_receiver) = unbounded::<()>();\n\n                            if self.watching {\n                                let p = self.file_path.as_ref().expect(\"Inconsistent state\");\n                                watcher.unwatch(p).unwrap_or_else(|_| panic!(\"Failed to unwatch {:?}\", p));\n                            }\n                            self.file_path = None;\n                            self.watching = false;\n\n                            if let Some(p) = file_path {\n                                self.file_path = Some(p.clone());\n\n                                let interval = self.interval;\n                                thread::spawn({\n                                    let p = p.clone();\n                                    move || FileReader::new(_content_sender, _watch_receiver, p, interval).run()\n                                });\n\n                                self.watching = watcher.watch(Path::new(&p), RecursiveMode::NonRecursive).is_ok();\n                            } else {\n                                _content_sender.send(Ok(\"\".to_string())).unwrap();\n                            }\n                        }\n                    }\n                }\n                recv(watch_receiver) -> _ => { _watch_sender.send(()).unwrap(); }\n                recv(_content_receiver) -> msg => {\n                    let res = msg.unwrap();\n                    // If we don't have a file watch yet but the file now reads OK, try enabling watch\n                    if !self.watching {\n                        if let (Ok(_), Some(p)) = (&res, &self.file_path) {\n                            self.watching = watcher.watch(Path::new(p), RecursiveMode::NonRecursive).is_ok();\n                        }\n                    }\n                    self.app\n                        .send(AppMessage::JobOutput(res.map_err(FileWatcherError::File)))\n                        .unwrap();\n                }\n            }\n        }\n    }\n}\n\nimpl FileReader {\n    fn new(\n        content_sender: Sender<io::Result<String>>,\n        receiver: Receiver<()>,\n        file_path: PathBuf,\n        interval: Duration,\n    ) -> Self {\n        FileReader {\n            content_sender,\n            receiver,\n            file_path,\n            interval,\n            content: \"\".to_string(),\n            pos: 0,\n        }\n    }\n\n    fn run(&mut self) -> Result<(), ()> {\n        loop {\n            self.update().map_err(|_| ())?;\n            select! {\n                recv(self.receiver) -> msg => {\n                    msg.map_err(|_| ())?;\n                }\n                // in case the file watcher doesn't work (e.g. network mounted fs)\n                default(self.interval) => {}\n            }\n        }\n    }\n\n    fn update(&mut self) -> Result<(), SendError<io::Result<String>>> {\n        let s = File::open(&self.file_path).and_then(|mut f| {\n            // avoid reading the whole file every time\n            self.pos = f.seek(io::SeekFrom::Start(self.pos))?;\n            self.pos += f.read_to_string(&mut self.content)? as u64;\n            Ok(self.content.clone())\n        });\n        // let s = fs::read_to_string(&self.file_path); // alternative: always read the whole file\n        self.content_sender.send(s)\n    }\n}\n\nimpl FileWatcherHandle {\n    pub fn new(app: Sender<AppMessage>, interval: Duration) -> Self {\n        let (sender, receiver) = unbounded();\n        let mut actor = FileWatcher::new(app, receiver, interval);\n        thread::spawn(move || actor.run());\n\n        Self {\n            sender,\n            file_path: None,\n        }\n    }\n\n    pub fn set_file_path(&mut self, file_path: Option<PathBuf>) {\n        if self.file_path != file_path {\n            self.file_path = file_path.clone();\n            self.sender\n                .send(FileWatcherMessage::FilePath(file_path))\n                .unwrap();\n        }\n    }\n}\n"
  },
  {
    "path": "src/job_watcher.rs",
    "content": "use std::path::PathBuf;\nuse std::{io::BufRead, process::Command, thread, time::Duration};\n\nuse crossbeam::channel::Sender;\nuse regex::Regex;\n\nuse crate::app::AppMessage;\nuse crate::app::Job;\n\nstruct JobWatcher {\n    app: Sender<AppMessage>,\n    interval: Duration,\n    squeue_args: Vec<String>,\n}\n\npub struct JobWatcherHandle {}\n\nimpl JobWatcher {\n    fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: Vec<String>) -> Self {\n        Self {\n            app,\n            interval,\n            squeue_args,\n        }\n    }\n\n    fn run(&mut self) -> Self {\n        let output_separator = \"###turm###\";\n        let fields = [\n            \"jobid\",\n            \"name\",\n            \"state\",\n            \"username\",\n            \"timeused\",\n            \"timelimit\",\n            \"StartTime\",\n            \"tres-alloc\",\n            \"partition\",\n            \"nodelist\",\n            \"stdout\",\n            \"stderr\",\n            \"command\",\n            \"statecompact\",\n            \"reason\",\n            \"ArrayJobID\",  // %A\n            \"ArrayTaskID\", // %a\n            \"NodeList\",    // %N\n            \"WorkDir\",     // for fallback\n        ];\n        let output_format = fields\n            .map(|s| s.to_owned() + \":\" + output_separator)\n            .join(\",\");\n\n        loop {\n            let jobs: Vec<Job> = Command::new(\"squeue\")\n                .args(&self.squeue_args)\n                .arg(\"--array\")\n                .arg(\"--noheader\")\n                .arg(\"--Format\")\n                .arg(&output_format)\n                .output()\n                .expect(\"failed to execute process\")\n                .stdout\n                .lines()\n                .map(|l| l.unwrap().trim().to_string())\n                .filter_map(|l| {\n                    let parts: Vec<_> = l.split(output_separator).collect();\n\n                    if parts.len() != fields.len() + 1 {\n                        return None;\n                    }\n\n                    let id = parts[0];\n                    let name = parts[1];\n                    let state = parts[2];\n                    let user = parts[3];\n                    let time = parts[4];\n                    let time_limit = parts[5];\n                    let start_time = parts[6];\n                    let tres = parts[7];\n                    let partition = parts[8];\n                    let nodelist = parts[9];\n                    let stdout = parts[10];\n                    let stderr = parts[11];\n                    let command = parts[12];\n                    let state_compact = parts[13];\n                    let reason = parts[14];\n\n                    let array_job_id = parts[15];\n                    let array_task_id = parts[16];\n                    let node_list = parts[17];\n                    let working_dir = parts[18];\n\n                    Some(Job {\n                        job_id: id.to_owned(),\n                        array_id: array_job_id.to_owned(),\n                        array_step: match array_task_id {\n                            \"N/A\" => None,\n                            _ => Some(array_task_id.to_owned()),\n                        },\n                        name: name.to_owned(),\n                        state: state.to_owned(),\n                        state_compact: state_compact.to_owned(),\n                        reason: if reason == \"None\" {\n                            None\n                        } else {\n                            Some(reason.to_owned())\n                        },\n                        user: user.to_owned(),\n                        time: time.to_owned(),\n                        time_limit: time_limit.to_owned(),\n                        start_time: start_time.to_owned(),\n                        tres: tres.to_owned(),\n                        partition: partition.to_owned(),\n                        nodelist: nodelist.to_owned(),\n                        command: command.to_owned(),\n                        stdout: Self::resolve_path(\n                            stdout,\n                            array_job_id,\n                            array_task_id,\n                            id,\n                            node_list,\n                            user,\n                            name,\n                            working_dir,\n                        ),\n                        stderr: Self::resolve_path(\n                            stderr,\n                            array_job_id,\n                            array_task_id,\n                            id,\n                            node_list,\n                            user,\n                            name,\n                            working_dir,\n                        ), // TODO fill all fields\n                    })\n                })\n                .collect();\n            self.app.send(AppMessage::Jobs(jobs)).unwrap();\n            thread::sleep(self.interval);\n        }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn resolve_path(\n        path: &str,\n        array_master: &str,\n        array_id: &str,\n        id: &str,\n        host: &str,\n        user: &str,\n        name: &str,\n        working_dir: &str,\n    ) -> Option<PathBuf> {\n        // see https://slurm.schedmd.com/sbatch.html#SECTION_%3CB%3Efilename-pattern%3C/B%3E\n        lazy_static::lazy_static! {\n            static ref RE: Regex = Regex::new(r\"%(%|A|a|J|j|N|n|s|t|u|x)\").unwrap();\n        }\n\n        let mut path = path.to_owned();\n        let slurm_no_val = \"4294967294\";\n        let array_id = if array_id == \"N/A\" {\n            slurm_no_val\n        } else {\n            array_id\n        };\n\n        if path.is_empty() {\n            // never happens right now, because `squeue -O stdout` seems to always return something\n            path = if array_id == slurm_no_val {\n                PathBuf::from(working_dir).join(\"slurm-%J.out\")\n            } else {\n                PathBuf::from(working_dir).join(\"slurm-%A_%a.out\")\n            }\n            .to_str()\n            .unwrap()\n            .to_owned();\n        };\n\n        for cap in RE\n            .captures_iter(&path.clone())\n            .collect::<Vec<_>>() // TODO: this is stupid, there has to be a better way to reverse the captures...\n            .iter()\n            .rev()\n        {\n            let m = cap.get(0).unwrap();\n            let replacement = match m.as_str() {\n                \"%%\" => \"%\",\n                \"%A\" => array_master,\n                \"%a\" => array_id,\n                \"%J\" => id,\n                \"%j\" => id,\n                \"%N\" => host.split(',').next().unwrap_or(host),\n                \"%n\" => \"0\",\n                \"%s\" => \"batch\",\n                \"%t\" => \"0\",\n                \"%u\" => user,\n                \"%x\" => name,\n                _ => unreachable!(),\n            };\n\n            path.replace_range(m.range(), replacement);\n        }\n\n        Some(PathBuf::from(working_dir).join(path)) // works even if `path` is absolute\n    }\n}\n\nimpl JobWatcherHandle {\n    pub fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: Vec<String>) -> Self {\n        let mut actor = JobWatcher::new(app, interval, squeue_args);\n        thread::spawn(move || actor.run());\n\n        Self {}\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "mod app;\nmod file_watcher;\nmod job_watcher;\nmod squeue_args;\n\nuse app::App;\nuse clap::CommandFactory;\nuse clap::Parser;\nuse clap::Subcommand;\nuse clap_complete::{Shell, generate};\nuse crossbeam::channel::{Sender, unbounded};\nuse crossterm::{\n    cursor::Show,\n    event::{\n        self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,\n        Event,\n    },\n    execute,\n    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},\n};\nuse ratatui::{\n    Terminal,\n    backend::{Backend, CrosstermBackend},\n};\nuse squeue_args::SqueueArgs;\nuse std::io::Write;\nuse std::{io, panic, thread};\n\n#[derive(Parser)]\n#[command(author, version, about, long_about = None)]\nstruct Cli {\n    /// Refresh rate for the job watcher.\n    #[arg(long, value_name = \"SECONDS\", default_value_t = 2)]\n    slurm_refresh: u64,\n\n    /// Refresh rate for the file watcher.\n    #[arg(long, value_name = \"SECONDS\", default_value_t = 2)]\n    file_refresh: u64,\n\n    /// squeue arguments\n    #[command(flatten)]\n    squeue_args: SqueueArgs,\n\n    #[command(subcommand)]\n    command: Option<CliCommand>,\n}\n\n#[derive(Subcommand)]\nenum CliCommand {\n    /// Print shell completion script to stdout.\n    Completion {\n        /// The shell to generate completion for.\n        shell: Shell,\n    },\n}\n\nfn main() -> io::Result<()> {\n    let args = Cli::parse();\n    match args.command {\n        Some(CliCommand::Completion { shell }) => {\n            let cmd = &mut Cli::command();\n            generate(shell, cmd, cmd.get_name().to_string(), &mut io::stdout());\n            return Ok(());\n        }\n        None => {}\n    }\n\n    install_panic_hook();\n\n    let mut terminal_guard = TerminalGuard::new(io::stdout())?;\n    run_app(terminal_guard.terminal_mut(), args)\n}\n\nfn install_panic_hook() {\n    let default_hook = panic::take_hook();\n    panic::set_hook(Box::new(move |panic_info| {\n        let _ = disable_raw_mode();\n        let _ = execute!(\n            io::stdout(),\n            LeaveAlternateScreen,\n            DisableBracketedPaste,\n            DisableMouseCapture,\n            Show\n        );\n        default_hook(panic_info);\n    }));\n}\n\nstruct TerminalGuard<W: Write> {\n    terminal: Terminal<CrosstermBackend<W>>,\n}\n\nimpl<W: Write> TerminalGuard<W> {\n    fn new(mut writer: W) -> io::Result<Self> {\n        enable_raw_mode()?;\n        execute!(\n            writer,\n            EnterAlternateScreen,\n            EnableBracketedPaste,\n            EnableMouseCapture\n        )?;\n        let backend = CrosstermBackend::new(writer);\n        let terminal = Terminal::new(backend)?;\n        Ok(Self { terminal })\n    }\n\n    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {\n        &mut self.terminal\n    }\n}\n\nimpl<W: Write> Drop for TerminalGuard<W> {\n    fn drop(&mut self) {\n        let _ = disable_raw_mode();\n        let _ = execute!(\n            self.terminal.backend_mut(),\n            LeaveAlternateScreen,\n            DisableBracketedPaste,\n            DisableMouseCapture\n        );\n        let _ = self.terminal.show_cursor();\n    }\n}\n\nfn input_loop(tx: Sender<std::io::Result<Event>>) {\n    while tx.send(event::read()).is_ok() {}\n}\n\nfn run_app<B: Backend<Error = io::Error>>(terminal: &mut Terminal<B>, args: Cli) -> io::Result<()> {\n    let (input_tx, input_rx) = unbounded();\n    let mut app = App::new(\n        input_rx,\n        args.slurm_refresh,\n        args.file_refresh,\n        args.squeue_args.to_vec(),\n    );\n    thread::spawn(move || input_loop(input_tx));\n    app.run(terminal)\n}\n"
  },
  {
    "path": "src/squeue_args.rs",
    "content": "use clap::Args;\n/// Doc comment\n#[derive(Args, Debug)]\npub struct SqueueArgs {\n    /// |squeue arg| Comma separated list of accounts to view, default is all accounts.\n    #[arg(short = 'A', long)]\n    account: Option<String>,\n\n    /// |squeue arg| Display jobs in hidden partitions.\n    #[arg(short, long)]\n    all: bool,\n\n    /// |squeue arg| Report federated information if a member of one.\n    #[arg(long)]\n    federation: bool,\n\n    /// |squeue arg| Do not display jobs in hidden partitions.\n    #[arg(long)]\n    hide: bool,\n\n    /// |squeue arg| Comma separated list of jobs IDs to view, default is all.\n    #[arg(short, long, value_name = \"JOBID\")]\n    job: Option<String>,\n\n    /// |squeue arg| Report information only about jobs on the local cluster. Overrides `--federation`.\n    #[arg(long)]\n    local: bool,\n\n    /// |squeue arg| Comma separated list of license names to view.\n    #[arg(short = 'L', long)]\n    licenses: Option<String>,\n\n    /// |squeue arg| Cluster to issue commands to. Default is current cluster. Cluster with no name will reset to default. Implies `--local`.\n    #[arg(short = 'M', long)]\n    clusters: Option<String>,\n\n    /// |squeue arg| Equivalent to `--user=<my username>`.\n    #[arg(long)]\n    me: bool,\n\n    /// |squeue arg| Comma separated list of job names to view.\n    #[arg(short = 'n', long)]\n    name: Option<String>,\n\n    /// |squeue arg| Don't convert units from their original type (e.g. 2048M won't be converted to 2G).\n    #[arg(long)]\n    noconvert: bool,\n\n    /// |squeue arg| Comma separated list of partitions to view, default is all partitions.\n    #[arg(short, long)]\n    partition: Option<String>,\n\n    /// |squeue arg| Comma separated list of qos's to view, default is all qos's.\n    #[arg(short, long)]\n    qos: Option<String>,\n\n    /// |squeue arg| Reservation to view, default is all.\n    #[arg(short = 'R', long)]\n    reservation: Option<String>,\n\n    /// |squeue arg| Report information about all sibling jobs on a federated cluster. Implies --federation.\n    #[arg(long)]\n    sibling: bool,\n\n    /// |squeue arg| Comma separated list of job steps to view, default is all.\n    #[arg(short, long)]\n    step: Option<String>,\n\n    /// |squeue arg| Comma separated list of fields to sort on.\n    #[arg(short = 'S', long, value_name = \"FIELDS\")]\n    sort: Option<String>,\n\n    /// |squeue arg| Comma separated list of states to view, default is pending and running, `--states=all` reports all states.\n    #[arg(short = 't', long)]\n    states: Option<String>,\n\n    /// |squeue arg| Comma separated list of users to view.\n    #[arg(short = 'u', long)]\n    user: Option<String>,\n\n    /// |squeue arg| List of nodes to view, default is all nodes.\n    #[arg(short = 'w', long, value_name = \"NODES\")]\n    nodelist: Option<String>,\n}\n\nimpl SqueueArgs {\n    pub fn to_vec(&self) -> Vec<String> {\n        let mut args = Vec::new();\n        if let Some(account) = &self.account {\n            args.push(format!(\"--account={}\", account));\n        }\n        if self.all {\n            args.push(\"--all\".to_string());\n        }\n        if self.federation {\n            args.push(\"--federation\".to_string());\n        }\n        if self.hide {\n            args.push(\"--hide\".to_string());\n        }\n        if let Some(job) = &self.job {\n            args.push(format!(\"--job={}\", job));\n        }\n        if self.local {\n            args.push(\"--local\".to_string());\n        }\n        if let Some(licenses) = &self.licenses {\n            args.push(format!(\"--licenses={}\", licenses));\n        }\n        if let Some(clusters) = &self.clusters {\n            args.push(format!(\"--clusters={}\", clusters));\n        }\n        if self.me {\n            args.push(\"--me\".to_string());\n        }\n        if let Some(name) = &self.name {\n            args.push(format!(\"--name={}\", name));\n        }\n        if self.noconvert {\n            args.push(\"--noconvert\".to_string());\n        }\n        if let Some(partition) = &self.partition {\n            args.push(format!(\"--partition={}\", partition));\n        }\n        if let Some(qos) = &self.qos {\n            args.push(format!(\"--qos={}\", qos));\n        }\n        if let Some(reservation) = &self.reservation {\n            args.push(format!(\"--reservation={}\", reservation));\n        }\n        if self.sibling {\n            args.push(\"--sibling\".to_string());\n        }\n        if let Some(step) = &self.step {\n            args.push(format!(\"--step={}\", step));\n        }\n        if let Some(sort) = &self.sort {\n            args.push(format!(\"--sort={}\", sort));\n        }\n        if let Some(states) = &self.states {\n            args.push(format!(\"--states={}\", states));\n        }\n        if let Some(user) = &self.user {\n            args.push(format!(\"--user={}\", user));\n        }\n        if let Some(nodelist) = &self.nodelist {\n            args.push(format!(\"--nodelist={}\", nodelist));\n        }\n        args\n    }\n}\n"
  }
]