railmux 0.4.0.dev8__tar.gz → 0.4.0.dev9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. {railmux-0.4.0.dev8/src/railmux.egg-info → railmux-0.4.0.dev9}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev9/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/cli.py +10 -2
  4. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/discovery.py +141 -0
  5. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_binding_manager.py +52 -13
  6. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_ctl.py +145 -43
  7. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/app.py +50 -1
  8. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_install_log.py +28 -8
  9. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_msys2.py +138 -4
  10. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_pacman.py +176 -0
  11. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9/src/railmux.egg-info}/PKG-INFO +1 -1
  12. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_discovery.py +113 -0
  13. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fullscreen_binding.py +41 -0
  14. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_binding_manager.py +39 -5
  15. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_ctl.py +36 -0
  16. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_integration.py +17 -2
  17. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_install_log.py +34 -0
  18. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_msys2.py +62 -7
  19. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_pacman.py +96 -0
  20. railmux-0.4.0.dev8/src/railmux/__init__.py +0 -1
  21. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/LICENSE +0 -0
  22. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/MANIFEST.in +0 -0
  23. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/README.md +0 -0
  24. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/pyproject.toml +0 -0
  25. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/setup.cfg +0 -0
  26. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/__main__.py +0 -0
  27. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/atomic_file.py +0 -0
  28. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/background_index.py +0 -0
  29. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/codex_index.py +0 -0
  30. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/config.py +0 -0
  31. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/config_cli.py +0 -0
  32. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/diagnostics.py +0 -0
  33. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/display_transport.py +0 -0
  34. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/entrypoint.py +0 -0
  35. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_client.py +0 -0
  36. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_history.py +0 -0
  37. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_input.py +0 -0
  38. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_protocol.py +0 -0
  39. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_server.py +0 -0
  40. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/favorites.py +0 -0
  41. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fuzzy.py +0 -0
  42. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/help_workspace.py +0 -0
  43. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/launcher.py +0 -0
  44. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/legacy_sessions.py +0 -0
  45. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/local_clipboard.py +0 -0
  46. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/local_open.py +0 -0
  47. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/models.py +0 -0
  48. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/modes.py +0 -0
  49. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/mouse_manager.py +0 -0
  50. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/orphan_marker.py +0 -0
  51. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/pane_surface.py +0 -0
  52. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/path_codec.py +0 -0
  53. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/provider_paths.py +0 -0
  54. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/remote_config.py +0 -0
  55. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/renames.py +0 -0
  56. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/restart_state.py +0 -0
  57. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/runtime_config.py +0 -0
  58. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/scroll_agent.py +0 -0
  59. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/scroll_manager.py +0 -0
  60. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/selection_isolation.py +0 -0
  61. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/self_update.py +0 -0
  62. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/session_cache.py +0 -0
  63. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/session_index.py +0 -0
  64. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/setting_contracts.py +0 -0
  65. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/settings.py +0 -0
  66. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_args.py +0 -0
  67. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_compat.py +0 -0
  68. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_display_diagnostics.py +0 -0
  69. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_doctor.py +0 -0
  70. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/system_deps.py +0 -0
  71. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/terminal_status.py +0 -0
  72. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_health.py +0 -0
  73. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_server.py +0 -0
  74. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tool_panes.py +0 -0
  75. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/transcript.py +0 -0
  76. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/__init__.py +0 -0
  77. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/_widgets.py +0 -0
  78. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/keymap.py +0 -0
  79. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/modals.py +0 -0
  80. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/projects_pane.py +0 -0
  81. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/running_pane.py +0 -0
  82. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/sessions_pane.py +0 -0
  83. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/sidebar.py +0 -0
  84. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/statusbar.py +0 -0
  85. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/workspace.py +0 -0
  86. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_bootstrap.py +0 -0
  87. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/SOURCES.txt +0 -0
  88. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/dependency_links.txt +0 -0
  89. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/entry_points.txt +0 -0
  90. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/requires.txt +0 -0
  91. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/top_level.txt +0 -0
  92. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_agent_workspace.py +0 -0
  93. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_atomic_file.py +0 -0
  94. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_background_index.py +0 -0
  95. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_cli.py +0 -0
  96. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_delete.py +0 -0
  97. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_index.py +0 -0
  98. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_lineage.py +0 -0
  99. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_transcript.py +0 -0
  100. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_config.py +0 -0
  101. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_config_cli.py +0 -0
  102. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_denested_transport_benchmark.py +0 -0
  103. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_diagnostics.py +0 -0
  104. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_display_transport.py +0 -0
  105. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fast_full_window.py +0 -0
  106. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_focus_highlight.py +0 -0
  107. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fuzzy.py +0 -0
  108. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_help_agent.py +0 -0
  109. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_help_workspace.py +0 -0
  110. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_history_cleanup.py +0 -0
  111. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_keymap.py +0 -0
  112. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_launcher.py +0 -0
  113. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_layout_profile.py +0 -0
  114. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_legacy_sessions.py +0 -0
  115. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_local_clipboard.py +0 -0
  116. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_local_open.py +0 -0
  117. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_modals.py +0 -0
  118. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_mode_selection.py +0 -0
  119. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_modes.py +0 -0
  120. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_mouse_manager.py +0 -0
  121. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_new_project.py +0 -0
  122. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_options.py +0 -0
  123. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_orphan_recovery.py +0 -0
  124. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_pane_surface.py +0 -0
  125. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_path_codec.py +0 -0
  126. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_projects_pane.py +0 -0
  127. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_provider_paths.py +0 -0
  128. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_release_notes.py +0 -0
  129. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_release_workflow.py +0 -0
  130. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_remote_config.py +0 -0
  131. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_renames.py +0 -0
  132. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_restart_state.py +0 -0
  133. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_running_pane.py +0 -0
  134. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_running_sort.py +0 -0
  135. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_runtime_config.py +0 -0
  136. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_scroll_agent.py +0 -0
  137. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_scroll_manager.py +0 -0
  138. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_selection_isolation.py +0 -0
  139. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_self_update.py +0 -0
  140. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_session_cache.py +0 -0
  141. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_session_index.py +0 -0
  142. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_sessions_pane.py +0 -0
  143. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_setting_contracts.py +0 -0
  144. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_settings.py +0 -0
  145. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_sidebar.py +0 -0
  146. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_soft_quit.py +0 -0
  147. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_args.py +0 -0
  148. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_compat.py +0 -0
  149. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_display_benchmark.py +0 -0
  150. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_display_diagnostics.py +0 -0
  151. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_doctor.py +0 -0
  152. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_status.py +0 -0
  153. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_statusbar.py +0 -0
  154. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_system_deps.py +0 -0
  155. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_terminal_size.py +0 -0
  156. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_terminal_status.py +0 -0
  157. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_termux_local_touch.py +0 -0
  158. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_health.py +0 -0
  159. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_server.py +0 -0
  160. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_status.py +0 -0
  161. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tool_panes.py +0 -0
  162. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_transcript.py +0 -0
  163. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_utf8_state.py +0 -0
  164. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_web_demo_recorder.py +0 -0
  165. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_wheel_smoke.py +0 -0
  166. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_widgets.py +0 -0
  167. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_bootstrap.py +0 -0
  168. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_wrapper_contract.py +0 -0
  169. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_yolo_prompt.py +0 -0
  170. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/denested_transport_benchmark.py +0 -0
  171. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/release_notes.py +0 -0
  172. {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev8
3
+ Version: 0.4.0.dev9
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev9"
@@ -24,12 +24,15 @@ from railmux.system_deps import ensure_tmux_available
24
24
  from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
25
25
 
26
26
 
27
- def _show_startup_message() -> None:
27
+ def _show_startup_message(
28
+ detail: str = "Reconnecting sessions and panes…",
29
+ ) -> None:
28
30
  """Paint immediate feedback before App performs its initial discovery."""
29
31
  if not sys.stdout.isatty():
30
32
  return
31
33
  size = shutil.get_terminal_size((80, 24))
32
- sys.stdout.write(render_startup_surface(size.columns, size.lines))
34
+ sys.stdout.write(render_startup_surface(
35
+ size.columns, size.lines, detail=detail))
33
36
  sys.stdout.flush()
34
37
 
35
38
 
@@ -441,6 +444,7 @@ def main(argv: list[str] | None = None) -> int:
441
444
  _show_startup_message()
442
445
  # Lazy import so non-TUI invocations (--version etc) don't pull urwid.
443
446
  from railmux.ui.app import App
447
+ from railmux.provider_paths import running_in_windows_wrapper
444
448
  app = App(
445
449
  claude_home=Path(args.claude_home),
446
450
  config=config,
@@ -451,6 +455,10 @@ def main(argv: list[str] | None = None) -> int:
451
455
  is_ssh_session() if args.scroll_coalescing is None
452
456
  else args.scroll_coalescing
453
457
  ),
458
+ startup_progress=(
459
+ _show_startup_message
460
+ if running_in_windows_wrapper() else None
461
+ ),
454
462
  )
455
463
  app.run()
456
464
  return 0
@@ -1,8 +1,10 @@
1
1
  """Enumerate Claude project directories from ~/.claude/projects/."""
2
2
  from __future__ import annotations
3
3
 
4
+ import hashlib
4
5
  import json
5
6
  import os
7
+ import stat
6
8
  from pathlib import Path
7
9
 
8
10
  from railmux.atomic_file import atomic_write_text
@@ -19,6 +21,134 @@ _cache: dict[Path, tuple[int, list[Project]]] = {}
19
21
  _session_validity: dict[
20
22
  Path, dict[Path, tuple[tuple[int, int, int], bool]]
21
23
  ] = {}
24
+ _persistent_validity_loaded: set[Path] = set()
25
+ _persistent_validity_exact_only: set[Path] = set()
26
+ _persistent_validity_dirty: set[Path] = set()
27
+ _VALIDITY_CACHE_SCHEMA = 1
28
+ _VALIDITY_CACHE_LIMIT = 16 * 1024 * 1024
29
+ _VALIDITY_RECORD_LIMIT = 100_000
30
+
31
+
32
+ def _windows_validity_cache_enabled() -> bool:
33
+ return os.environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
34
+
35
+
36
+ def _validity_cache_file(claude_home: Path) -> Path:
37
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
38
+ identity = hashlib.sha256(
39
+ os.fsencode(os.path.abspath(claude_home))
40
+ ).hexdigest()[:16]
41
+ return base / "railmux" / f"claude-validity-{identity}.json"
42
+
43
+
44
+ def _load_persistent_validity(claude_home: Path) -> None:
45
+ if (
46
+ not _windows_validity_cache_enabled()
47
+ or claude_home in _persistent_validity_loaded
48
+ ):
49
+ return
50
+ _persistent_validity_loaded.add(claude_home)
51
+ path = _validity_cache_file(claude_home)
52
+ try:
53
+ info = path.lstat()
54
+ if (
55
+ not stat.S_ISREG(info.st_mode)
56
+ or info.st_uid != os.getuid()
57
+ or info.st_size > _VALIDITY_CACHE_LIMIT
58
+ or info.st_mode & 0o077
59
+ ):
60
+ return
61
+ raw = json.loads(path.read_text(encoding="utf-8"))
62
+ except (OSError, UnicodeError, json.JSONDecodeError):
63
+ return
64
+ if not isinstance(raw, dict) or raw.get("schema") != _VALIDITY_CACHE_SCHEMA:
65
+ return
66
+ records = raw.get("records")
67
+ if not isinstance(records, list) or len(records) > _VALIDITY_RECORD_LIMIT:
68
+ return
69
+ projects_dir = claude_home / "projects"
70
+ loaded: list[Path] = []
71
+ try:
72
+ for record in records:
73
+ if not isinstance(record, list) or len(record) != 6:
74
+ raise ValueError("invalid validity record")
75
+ project, filename, inode, mtime_ns, size, valid = record
76
+ if (
77
+ not isinstance(project, str)
78
+ or not is_encoded_project_name(project)
79
+ or not isinstance(filename, str)
80
+ or Path(filename).name != filename
81
+ or not filename.endswith(".jsonl")
82
+ or not _looks_like_uuid(Path(filename).stem)
83
+ or not all(isinstance(value, int) and value >= 0
84
+ for value in (inode, mtime_ns, size))
85
+ or not isinstance(valid, bool)
86
+ ):
87
+ raise ValueError("invalid validity record")
88
+ project_dir = projects_dir / project
89
+ session_path = project_dir / filename
90
+ _session_validity.setdefault(project_dir, {})[session_path] = (
91
+ (inode, mtime_ns, size), valid,
92
+ )
93
+ loaded.append(session_path)
94
+ except ValueError:
95
+ for session_path in loaded:
96
+ cached = _session_validity.get(session_path.parent)
97
+ if cached is not None:
98
+ cached.pop(session_path, None)
99
+ if not cached:
100
+ _session_validity.pop(session_path.parent, None)
101
+ return
102
+ _persistent_validity_exact_only.update(loaded)
103
+
104
+
105
+ def _mark_persistent_validity_dirty(claude_dir: Path) -> None:
106
+ if _windows_validity_cache_enabled():
107
+ _persistent_validity_dirty.add(claude_dir.parent.parent)
108
+
109
+
110
+ def _save_persistent_validity(claude_home: Path) -> None:
111
+ if (
112
+ not _windows_validity_cache_enabled()
113
+ or claude_home not in _persistent_validity_dirty
114
+ ):
115
+ return
116
+ projects_dir = claude_home / "projects"
117
+ records: list[list[object]] = []
118
+ for project_dir, values in _session_validity.items():
119
+ if (
120
+ project_dir.parent != projects_dir
121
+ or not is_encoded_project_name(project_dir.name)
122
+ ):
123
+ continue
124
+ for session_path, (signature, valid) in values.items():
125
+ if session_path.parent != project_dir:
126
+ continue
127
+ inode, mtime_ns, size = signature
128
+ records.append([
129
+ project_dir.name,
130
+ session_path.name,
131
+ inode,
132
+ mtime_ns,
133
+ size,
134
+ valid,
135
+ ])
136
+ records.sort(key=lambda item: int(item[3]), reverse=True)
137
+ records = records[:_VALIDITY_RECORD_LIMIT]
138
+ path = _validity_cache_file(claude_home)
139
+ try:
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ atomic_write_text(
142
+ path,
143
+ json.dumps(
144
+ {"schema": _VALIDITY_CACHE_SCHEMA, "records": records},
145
+ separators=(",", ":"),
146
+ ),
147
+ )
148
+ os.chmod(path, 0o600)
149
+ except OSError:
150
+ return
151
+ _persistent_validity_dirty.discard(claude_home)
22
152
 
23
153
 
24
154
  def invalidate_session(path: Path) -> None:
@@ -27,6 +157,8 @@ def invalidate_session(path: Path) -> None:
27
157
  if cached is None:
28
158
  return
29
159
  cached.pop(path, None)
160
+ _persistent_validity_exact_only.discard(path)
161
+ _mark_persistent_validity_dirty(path.parent)
30
162
  if not cached:
31
163
  _session_validity.pop(path.parent, None)
32
164
 
@@ -64,6 +196,7 @@ def list_projects(claude_home: Path) -> list[Project]:
64
196
  session counts and recency track files created, changed, or removed below
65
197
  the cached top-level directory.
66
198
  """
199
+ _load_persistent_validity(claude_home)
67
200
  projects_dir = claude_home / "projects"
68
201
  if not projects_dir.is_dir():
69
202
  return []
@@ -79,6 +212,7 @@ def list_projects(claude_home: Path) -> list[Project]:
79
212
  # without re-decoding every project name.
80
213
  refreshed = _refresh_activity(cached[1])
81
214
  _cache[claude_home] = (parent_mtime, refreshed)
215
+ _save_persistent_validity(claude_home)
82
216
  return refreshed
83
217
 
84
218
  # Full scan needed.
@@ -153,6 +287,7 @@ def list_projects(claude_home: Path) -> list[Project]:
153
287
 
154
288
  results.sort(key=lambda p: p.last_activity_ts, reverse=True)
155
289
  _cache[claude_home] = (parent_mtime, results)
290
+ _save_persistent_validity(claude_home)
156
291
  return results
157
292
 
158
293
 
@@ -198,6 +333,7 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
198
333
  if cached is not None and cached[0] == signature:
199
334
  valid = cached[1]
200
335
  elif (cached is not None and cached[1]
336
+ and path not in _persistent_validity_exact_only
201
337
  and cached[0][0] == stat.st_ino
202
338
  and stat.st_size > cached[0][2]):
203
339
  # Claude session JSONLs are append-only. Once a file is valid,
@@ -208,9 +344,12 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
208
344
  # explicitly via ``invalidate_session``).
209
345
  valid = True
210
346
  validity[path] = (signature, True)
347
+ _mark_persistent_validity_dirty(claude_dir)
211
348
  else:
212
349
  valid = _scan_session(temp_project, path) is not None
213
350
  validity[path] = (signature, valid)
351
+ _persistent_validity_exact_only.discard(path)
352
+ _mark_persistent_validity_dirty(claude_dir)
214
353
  if not valid:
215
354
  continue
216
355
  count += 1
@@ -219,6 +358,8 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
219
358
  latest = m
220
359
  for stale in set(validity) - current_paths:
221
360
  del validity[stale]
361
+ _persistent_validity_exact_only.discard(stale)
362
+ _mark_persistent_validity_dirty(claude_dir)
222
363
  if not validity:
223
364
  _session_validity.pop(claude_dir, None)
224
365
  return count, latest
@@ -14,8 +14,14 @@ from railmux import restart_state, tmux_ctl
14
14
  from railmux.atomic_file import atomic_write_text
15
15
 
16
16
 
17
- _VERSION = 8
17
+ _VERSION = 9
18
18
  _KEYS = ("F8", "F9")
19
+ _STATUS_CLICK_KEYS = (
20
+ "MouseDown1Status",
21
+ "MouseDown1Control0",
22
+ "MouseDown1Control1",
23
+ "MouseDown1Control2",
24
+ )
19
25
  _MAX_STATE_BYTES = 64 * 1024
20
26
 
21
27
 
@@ -114,12 +120,18 @@ class SharedTmuxBindingManager:
114
120
  and all(value is None or isinstance(value, str)
115
121
  for value in right_click_backup.values())
116
122
  )
117
- status_click_valid = (
123
+ legacy_status_click_valid = (
118
124
  isinstance(status_click_backup, dict)
119
125
  and set(status_click_backup) == {"MouseDown1Status"}
120
126
  and all(value is None or isinstance(value, str)
121
127
  for value in status_click_backup.values())
122
128
  )
129
+ status_click_valid = (
130
+ isinstance(status_click_backup, dict)
131
+ and set(status_click_backup) == set(_STATUS_CLICK_KEYS)
132
+ and all(value is None or isinstance(value, str)
133
+ for value in status_click_backup.values())
134
+ )
123
135
  termux_tap_valid = (
124
136
  isinstance(termux_tap_backup, dict)
125
137
  and set(termux_tap_backup) == {"MouseDown1Pane"}
@@ -149,7 +161,13 @@ class SharedTmuxBindingManager:
149
161
  or (not selection_hook_managed
150
162
  and selection_hook_index is not None)))
151
163
  or (raw["version"] >= 5
152
- and (not status_click_valid
164
+ and ((raw["version"] < 9
165
+ and not (
166
+ legacy_status_click_valid
167
+ or status_click_valid
168
+ ))
169
+ or (raw["version"] >= 9
170
+ and not status_click_valid)
153
171
  or not isinstance(status_click_managed, bool)))
154
172
  or (raw["version"] >= 8
155
173
  and (not termux_tap_valid
@@ -293,6 +311,29 @@ class SharedTmuxBindingManager:
293
311
  or {"MouseDown1Pane": None}
294
312
  )
295
313
  upgraded = True
314
+ if state["version"] < 9:
315
+ if state["status_click_managed"]:
316
+ if set(state["status_click_backup"]) != set(
317
+ _STATUS_CLICK_KEYS
318
+ ):
319
+ status_click_backup = (
320
+ tmux_ctl.upgrade_root_status_click_binding(
321
+ state["status_click_backup"],
322
+ state["token"],
323
+ )
324
+ )
325
+ if status_click_backup is None:
326
+ return False
327
+ state["status_click_backup"] = (
328
+ status_click_backup)
329
+ else:
330
+ state["status_click_backup"] = {
331
+ key: None for key in _STATUS_CLICK_KEYS
332
+ }
333
+ # tmux 3.7 routes control ranges through dedicated
334
+ # MouseDown1ControlN keys. Capture all originals before
335
+ # reinstalling the shared status-click lease.
336
+ upgraded = True
296
337
  if upgraded:
297
338
  state["version"] = _VERSION
298
339
  state["phase"] = "installing"
@@ -341,17 +382,14 @@ class SharedTmuxBindingManager:
341
382
  )
342
383
  if state["status_click_managed"]:
343
384
  current_status = (
344
- tmux_ctl.read_root_status_click_binding()
345
- ["MouseDown1Status"]
346
- )
347
- safe = (
348
- safe
349
- and tmux_ctl.root_status_click_binding_is_original_or_owned(
350
- current_status,
351
- state["status_click_backup"].get(
352
- "MouseDown1Status"),
385
+ tmux_ctl.read_root_status_click_binding())
386
+ safe = safe and all(
387
+ tmux_ctl.root_status_click_binding_is_original_or_owned(
388
+ current_status.get(key),
389
+ state["status_click_backup"].get(key),
353
390
  token,
354
391
  )
392
+ for key in _STATUS_CLICK_KEYS
355
393
  )
356
394
  if state["termux_tap_managed"]:
357
395
  current_tap = (
@@ -543,7 +581,8 @@ class SharedTmuxBindingManager:
543
581
  "selection_hook_index": selection_hook_index,
544
582
  "status_click_managed": status_click_backup is not None,
545
583
  "status_click_backup": (
546
- status_click_backup or {"MouseDown1Status": None}),
584
+ status_click_backup
585
+ or {key: None for key in _STATUS_CLICK_KEYS}),
547
586
  "termux_tap_managed": termux_tap_backup is not None,
548
587
  "termux_tap_backup": (
549
588
  termux_tap_backup or {"MouseDown1Pane": None}),
@@ -1815,6 +1815,15 @@ _ROOT_TERMUX_TAP_MARKER = "railmux-termux-tap-v1"
1815
1815
  STATUS_ACTION_MODE = "railmux-mode"
1816
1816
  STATUS_ACTION_LAYOUT = "railmux-layout"
1817
1817
  STATUS_ACTION_COPY = "railmux-copy"
1818
+ _ROOT_STATUS_ACTION_KEYS = {
1819
+ STATUS_ACTION_MODE: "MouseDown1Control0",
1820
+ STATUS_ACTION_LAYOUT: "MouseDown1Control1",
1821
+ STATUS_ACTION_COPY: "MouseDown1Control2",
1822
+ }
1823
+ _ROOT_STATUS_CLICK_KEYS = (
1824
+ _ROOT_STATUS_CLICK_KEY,
1825
+ *_ROOT_STATUS_ACTION_KEYS.values(),
1826
+ )
1818
1827
  _STATUS_ACTIONS = frozenset({
1819
1828
  STATUS_ACTION_MODE,
1820
1829
  STATUS_ACTION_LAYOUT,
@@ -1897,6 +1906,14 @@ def status_action_range(action: str, content: str) -> str:
1897
1906
  raise ValueError(f"invalid Railmux status action: {action!r}")
1898
1907
  if not status_pane_ranges_supported():
1899
1908
  return content
1909
+ # tmux 3.7 gives control ranges distinct root-table mouse keys. Using those
1910
+ # keys avoids routing fixed actions through the generic user-range
1911
+ # dispatcher and provides deterministic bindings on the 3.7b version
1912
+ # bundled by the managed Windows runtime.
1913
+ if tmux_version() >= (3, 7):
1914
+ key = _ROOT_STATUS_ACTION_KEYS[action]
1915
+ index = key.removeprefix("MouseDown1Control")
1916
+ return f"#[range=control|{index}]{content}#[norange]"
1900
1917
  return f"#[range=user|{action}]{content}#[norange]"
1901
1918
 
1902
1919
 
@@ -2752,27 +2769,100 @@ def restore_root_termux_tap_binding(
2752
2769
 
2753
2770
 
2754
2771
  def read_root_status_click_binding() -> RootStatusClickBindingBackup:
2755
- """Capture the root binding used for pane ranges in the status line."""
2772
+ """Capture every root binding used by status ranges and controls."""
2756
2773
  return {
2757
- _ROOT_STATUS_CLICK_KEY:
2758
- _read_key_binding("root", _ROOT_STATUS_CLICK_KEY),
2774
+ key: _read_key_binding("root", key)
2775
+ for key in _ROOT_STATUS_CLICK_KEYS
2759
2776
  }
2760
2777
 
2761
2778
 
2779
+ def _status_click_backup_replayable(
2780
+ backup: RootStatusClickBindingBackup,
2781
+ ) -> bool:
2782
+ if set(backup) != set(_ROOT_STATUS_CLICK_KEYS):
2783
+ return False
2784
+ for binding in backup.values():
2785
+ if binding is None:
2786
+ continue
2787
+ try:
2788
+ _binding_command(binding)
2789
+ except ValueError:
2790
+ return False
2791
+ return True
2792
+
2793
+
2762
2794
  def prepare_root_status_click_binding() -> RootStatusClickBindingBackup | None:
2763
2795
  """Return a replayable status-click binding, or decline management."""
2764
2796
  if not status_pane_ranges_supported():
2765
2797
  return None
2766
2798
  backup = read_root_status_click_binding()
2767
- binding = backup[_ROOT_STATUS_CLICK_KEY]
2768
- if binding and _ROOT_STATUS_CLICK_MARKER in binding:
2799
+ if any(
2800
+ binding and _ROOT_STATUS_CLICK_MARKER in binding
2801
+ for binding in backup.values()
2802
+ ):
2769
2803
  return None
2770
- if binding is not None:
2771
- try:
2772
- _binding_command(binding)
2773
- except ValueError:
2774
- return None
2775
- return backup
2804
+ return backup if _status_click_backup_replayable(backup) else None
2805
+
2806
+
2807
+ def upgrade_root_status_click_binding(
2808
+ previous: RootStatusClickBindingBackup,
2809
+ token: str,
2810
+ ) -> RootStatusClickBindingBackup | None:
2811
+ """Expand a pre-control-range lease without losing user bindings.
2812
+
2813
+ Versions 5-8 saved only MouseDown1Status. Before version 9 installs the
2814
+ tmux 3.7 control keys it captures their exact current commands, while also
2815
+ verifying that the old status wrapper is still either original or ours.
2816
+ """
2817
+ if set(previous) != {_ROOT_STATUS_CLICK_KEY}:
2818
+ return None
2819
+ current = read_root_status_click_binding()
2820
+ if not root_status_click_binding_is_original_or_owned(
2821
+ current.get(_ROOT_STATUS_CLICK_KEY),
2822
+ previous.get(_ROOT_STATUS_CLICK_KEY),
2823
+ token,
2824
+ ):
2825
+ return None
2826
+ upgraded = dict(current)
2827
+ upgraded[_ROOT_STATUS_CLICK_KEY] = previous[_ROOT_STATUS_CLICK_KEY]
2828
+ return upgraded if _status_click_backup_replayable(upgraded) else None
2829
+
2830
+
2831
+ def _status_click_fallback(binding: str | None) -> str:
2832
+ return _binding_command(binding) if binding is not None else 'run-shell "true"'
2833
+
2834
+
2835
+ def _set_root_status_control_forwarding(
2836
+ backup: RootStatusClickBindingBackup,
2837
+ token: str,
2838
+ ) -> None:
2839
+ if tmux_version() < (3, 7):
2840
+ return
2841
+ marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
2842
+ action_keys = {
2843
+ STATUS_ACTION_MODE: "F5",
2844
+ STATUS_ACTION_LAYOUT: "F7",
2845
+ STATUS_ACTION_COPY: "F6",
2846
+ }
2847
+ condition = (
2848
+ "#{&&:"
2849
+ f"#{{!=:#{{{RAILMUX_CONTROLLER_OPTION}}},}},"
2850
+ f"#{{==:{marker},{marker}}}}}"
2851
+ )
2852
+ for action, key in _ROOT_STATUS_ACTION_KEYS.items():
2853
+ dispatch = (
2854
+ f"run-shell \"{_tmux_shell_executable()} send-keys -t "
2855
+ f"'#{{{RAILMUX_CONTROLLER_OPTION}}}' {action_keys[action]}\""
2856
+ )
2857
+ subprocess.check_call(
2858
+ [
2859
+ "tmux", "bind-key", "-T", "root", key,
2860
+ "if-shell", "-F", condition, dispatch,
2861
+ _status_click_fallback(backup.get(key)),
2862
+ ],
2863
+ stdout=subprocess.DEVNULL,
2864
+ stderr=subprocess.DEVNULL,
2865
+ )
2776
2866
 
2777
2867
 
2778
2868
  def set_root_status_click_forwarding(
@@ -2781,6 +2871,8 @@ def set_root_status_click_forwarding(
2781
2871
  """Dispatch Railmux user ranges and replay all other status clicks."""
2782
2872
  if not status_pane_ranges_supported():
2783
2873
  return False
2874
+ if not _status_click_backup_replayable(backup):
2875
+ return False
2784
2876
  marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
2785
2877
  condition = (
2786
2878
  "#{&&:"
@@ -2791,10 +2883,7 @@ def set_root_status_click_forwarding(
2791
2883
  )
2792
2884
  original = backup.get(_ROOT_STATUS_CLICK_KEY)
2793
2885
  try:
2794
- fallback = (
2795
- _binding_command(original)
2796
- if original is not None else 'run-shell "true"'
2797
- )
2886
+ fallback = _status_click_fallback(original)
2798
2887
  dispatch = (
2799
2888
  'run-shell "case \'#{mouse_status_range}\' in '
2800
2889
  f'%*) {_tmux_shell_executable()} select-pane -Z -t '
@@ -2820,21 +2909,33 @@ def set_root_status_click_forwarding(
2820
2909
  stdout=subprocess.DEVNULL,
2821
2910
  stderr=subprocess.DEVNULL,
2822
2911
  )
2912
+ _set_root_status_control_forwarding(backup, token)
2823
2913
  return True
2824
2914
  except (ValueError, subprocess.CalledProcessError, FileNotFoundError):
2915
+ # A later control-key write can fail after MouseDown1Status was
2916
+ # installed. Restore only bindings bearing this lease's marker.
2917
+ restore_root_status_click_binding(backup, token=token)
2825
2918
  return False
2826
2919
 
2827
2920
 
2828
2921
  def root_status_click_binding_owned_by(token: str) -> bool:
2829
2922
  marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
2830
- binding = read_root_status_click_binding().get(_ROOT_STATUS_CLICK_KEY)
2831
- return bool(
2832
- binding
2833
- and marker in binding
2923
+ current = read_root_status_click_binding()
2924
+ binding = current.get(_ROOT_STATUS_CLICK_KEY)
2925
+ base_owned = bool(
2926
+ binding and marker in binding
2834
2927
  and RAILMUX_CONTROLLER_OPTION in binding
2835
2928
  and "mouse_status_range" in binding
2836
2929
  and "select-pane -Z -t" in binding
2837
2930
  )
2931
+ if not base_owned or tmux_version() < (3, 7):
2932
+ return base_owned
2933
+ return all(
2934
+ (binding := current.get(key)) is not None
2935
+ and marker in binding
2936
+ and RAILMUX_CONTROLLER_OPTION in binding
2937
+ for key in _ROOT_STATUS_ACTION_KEYS.values()
2938
+ )
2838
2939
 
2839
2940
 
2840
2941
  def root_status_click_binding_is_original_or_owned(
@@ -2851,35 +2952,36 @@ def root_status_click_binding_is_original_or_owned(
2851
2952
  def restore_root_status_click_binding(
2852
2953
  backup: RootStatusClickBindingBackup, *, token: str,
2853
2954
  ) -> None:
2854
- """Restore MouseDown1Status only while the live wrapper is still ours."""
2855
- live = read_root_status_click_binding().get(_ROOT_STATUS_CLICK_KEY)
2955
+ """Restore status bindings only while each live wrapper is still ours."""
2956
+ live = read_root_status_click_binding()
2856
2957
  marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
2857
- if live is None or marker not in live:
2858
- return
2859
- original = backup.get(_ROOT_STATUS_CLICK_KEY)
2860
- try:
2861
- if original is None:
2862
- subprocess.check_call(
2863
- ["tmux", "unbind-key", "-T", "root",
2864
- _ROOT_STATUS_CLICK_KEY],
2865
- stdout=subprocess.DEVNULL,
2866
- stderr=subprocess.DEVNULL,
2867
- )
2868
- else:
2869
- fd, path = tempfile.mkstemp(
2870
- prefix="railmux-root-status-click-", suffix=".conf")
2871
- try:
2872
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
2873
- fh.write(original + "\n")
2958
+ for key in _ROOT_STATUS_CLICK_KEYS:
2959
+ current = live.get(key)
2960
+ if current is None or marker not in current:
2961
+ continue
2962
+ original = backup.get(key)
2963
+ try:
2964
+ if original is None:
2874
2965
  subprocess.check_call(
2875
- ["tmux", "source-file", path],
2966
+ ["tmux", "unbind-key", "-T", "root", key],
2876
2967
  stdout=subprocess.DEVNULL,
2877
2968
  stderr=subprocess.DEVNULL,
2878
2969
  )
2879
- finally:
2880
- os.unlink(path)
2881
- except (OSError, subprocess.CalledProcessError, FileNotFoundError):
2882
- pass
2970
+ else:
2971
+ fd, path = tempfile.mkstemp(
2972
+ prefix="railmux-root-status-click-", suffix=".conf")
2973
+ try:
2974
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2975
+ fh.write(original + "\n")
2976
+ subprocess.check_call(
2977
+ ["tmux", "source-file", path],
2978
+ stdout=subprocess.DEVNULL,
2979
+ stderr=subprocess.DEVNULL,
2980
+ )
2981
+ finally:
2982
+ os.unlink(path)
2983
+ except (OSError, subprocess.CalledProcessError, FileNotFoundError):
2984
+ pass
2883
2985
 
2884
2986
 
2885
2987
  def read_prefix_target_binding() -> PrefixTargetBindingBackup: