railmux 0.4.0.dev7__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 (175) hide show
  1. {railmux-0.4.0.dev7/src/railmux.egg-info → railmux-0.4.0.dev9}/PKG-INFO +4 -2
  2. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/README.md +3 -1
  3. railmux-0.4.0.dev9/src/railmux/__init__.py +1 -0
  4. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/cli.py +10 -2
  5. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/discovery.py +141 -0
  6. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_client.py +2 -2
  7. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_history.py +12 -3
  8. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_protocol.py +20 -7
  9. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_server.py +26 -3
  10. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_binding_manager.py +52 -13
  11. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_ctl.py +194 -43
  12. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/app.py +139 -1
  13. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/windows_bootstrap.py +5 -3
  14. railmux-0.4.0.dev9/src/railmux/windows_install_log.py +408 -0
  15. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/windows_msys2.py +350 -40
  16. railmux-0.4.0.dev9/src/railmux/windows_pacman.py +578 -0
  17. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9/src/railmux.egg-info}/PKG-INFO +4 -2
  18. railmux-0.4.0.dev9/tests/test_codex_lineage.py +157 -0
  19. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_discovery.py +113 -0
  20. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fast_full_window.py +125 -12
  21. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fullscreen_binding.py +41 -0
  22. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_binding_manager.py +39 -5
  23. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_ctl.py +73 -0
  24. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_integration.py +58 -2
  25. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_bootstrap.py +3 -0
  26. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_install_log.py +90 -0
  27. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_msys2.py +159 -11
  28. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_pacman.py +166 -5
  29. railmux-0.4.0.dev7/src/railmux/__init__.py +0 -1
  30. railmux-0.4.0.dev7/src/railmux/windows_install_log.py +0 -189
  31. railmux-0.4.0.dev7/src/railmux/windows_pacman.py +0 -259
  32. railmux-0.4.0.dev7/tests/test_codex_lineage.py +0 -64
  33. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/LICENSE +0 -0
  34. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/MANIFEST.in +0 -0
  35. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/pyproject.toml +0 -0
  36. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/setup.cfg +0 -0
  37. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/__main__.py +0 -0
  38. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/atomic_file.py +0 -0
  39. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/background_index.py +0 -0
  40. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/codex_index.py +0 -0
  41. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/config.py +0 -0
  42. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/config_cli.py +0 -0
  43. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/diagnostics.py +0 -0
  44. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/display_transport.py +0 -0
  45. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/entrypoint.py +0 -0
  46. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fast_display_input.py +0 -0
  47. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/favorites.py +0 -0
  48. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/fuzzy.py +0 -0
  49. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/help_workspace.py +0 -0
  50. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/launcher.py +0 -0
  51. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/legacy_sessions.py +0 -0
  52. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/local_clipboard.py +0 -0
  53. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/local_open.py +0 -0
  54. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/models.py +0 -0
  55. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/modes.py +0 -0
  56. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/mouse_manager.py +0 -0
  57. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/orphan_marker.py +0 -0
  58. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/pane_surface.py +0 -0
  59. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/path_codec.py +0 -0
  60. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/provider_paths.py +0 -0
  61. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/remote_config.py +0 -0
  62. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/renames.py +0 -0
  63. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/restart_state.py +0 -0
  64. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/runtime_config.py +0 -0
  65. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/scroll_agent.py +0 -0
  66. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/scroll_manager.py +0 -0
  67. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/selection_isolation.py +0 -0
  68. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/self_update.py +0 -0
  69. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/session_cache.py +0 -0
  70. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/session_index.py +0 -0
  71. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/setting_contracts.py +0 -0
  72. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/settings.py +0 -0
  73. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_args.py +0 -0
  74. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_compat.py +0 -0
  75. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_display_diagnostics.py +0 -0
  76. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ssh_doctor.py +0 -0
  77. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/system_deps.py +0 -0
  78. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/terminal_status.py +0 -0
  79. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_health.py +0 -0
  80. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tmux_server.py +0 -0
  81. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/tool_panes.py +0 -0
  82. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/transcript.py +0 -0
  83. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/__init__.py +0 -0
  84. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/_widgets.py +0 -0
  85. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/keymap.py +0 -0
  86. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/modals.py +0 -0
  87. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/projects_pane.py +0 -0
  88. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/running_pane.py +0 -0
  89. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/sessions_pane.py +0 -0
  90. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/sidebar.py +0 -0
  91. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/statusbar.py +0 -0
  92. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux/ui/workspace.py +0 -0
  93. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/SOURCES.txt +0 -0
  94. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/dependency_links.txt +0 -0
  95. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/entry_points.txt +0 -0
  96. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/requires.txt +0 -0
  97. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/src/railmux.egg-info/top_level.txt +0 -0
  98. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_agent_workspace.py +0 -0
  99. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_atomic_file.py +0 -0
  100. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_background_index.py +0 -0
  101. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_cli.py +0 -0
  102. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_delete.py +0 -0
  103. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_index.py +0 -0
  104. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_codex_transcript.py +0 -0
  105. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_config.py +0 -0
  106. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_config_cli.py +0 -0
  107. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_denested_transport_benchmark.py +0 -0
  108. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_diagnostics.py +0 -0
  109. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_display_transport.py +0 -0
  110. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_focus_highlight.py +0 -0
  111. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_fuzzy.py +0 -0
  112. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_help_agent.py +0 -0
  113. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_help_workspace.py +0 -0
  114. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_history_cleanup.py +0 -0
  115. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_keymap.py +0 -0
  116. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_launcher.py +0 -0
  117. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_layout_profile.py +0 -0
  118. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_legacy_sessions.py +0 -0
  119. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_local_clipboard.py +0 -0
  120. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_local_open.py +0 -0
  121. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_modals.py +0 -0
  122. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_mode_selection.py +0 -0
  123. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_modes.py +0 -0
  124. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_mouse_manager.py +0 -0
  125. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_new_project.py +0 -0
  126. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_options.py +0 -0
  127. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_orphan_recovery.py +0 -0
  128. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_pane_surface.py +0 -0
  129. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_path_codec.py +0 -0
  130. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_projects_pane.py +0 -0
  131. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_provider_paths.py +0 -0
  132. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_release_notes.py +0 -0
  133. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_release_workflow.py +0 -0
  134. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_remote_config.py +0 -0
  135. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_renames.py +0 -0
  136. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_restart_state.py +0 -0
  137. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_running_pane.py +0 -0
  138. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_running_sort.py +0 -0
  139. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_runtime_config.py +0 -0
  140. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_scroll_agent.py +0 -0
  141. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_scroll_manager.py +0 -0
  142. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_selection_isolation.py +0 -0
  143. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_self_update.py +0 -0
  144. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_session_cache.py +0 -0
  145. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_session_index.py +0 -0
  146. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_sessions_pane.py +0 -0
  147. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_setting_contracts.py +0 -0
  148. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_settings.py +0 -0
  149. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_sidebar.py +0 -0
  150. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_soft_quit.py +0 -0
  151. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_args.py +0 -0
  152. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_compat.py +0 -0
  153. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_display_benchmark.py +0 -0
  154. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_display_diagnostics.py +0 -0
  155. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_ssh_doctor.py +0 -0
  156. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_status.py +0 -0
  157. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_statusbar.py +0 -0
  158. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_system_deps.py +0 -0
  159. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_terminal_size.py +0 -0
  160. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_terminal_status.py +0 -0
  161. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_termux_local_touch.py +0 -0
  162. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_health.py +0 -0
  163. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_server.py +0 -0
  164. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tmux_status.py +0 -0
  165. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_tool_panes.py +0 -0
  166. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_transcript.py +0 -0
  167. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_utf8_state.py +0 -0
  168. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_web_demo_recorder.py +0 -0
  169. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_wheel_smoke.py +0 -0
  170. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_widgets.py +0 -0
  171. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_windows_wrapper_contract.py +0 -0
  172. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tests/test_yolo_prompt.py +0 -0
  173. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tools/denested_transport_benchmark.py +0 -0
  174. {railmux-0.4.0.dev7 → railmux-0.4.0.dev9}/tools/release_notes.py +0 -0
  175. {railmux-0.4.0.dev7 → 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.dev7
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
@@ -374,7 +374,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
374
374
  internal context and encrypted reasoning are hidden. Codex rewind lineages and
375
375
  Claude Code `parentUuid` branches are projected onto the provider's current
376
376
  conversation: the retained prefix and replacement suffix remain visible, while
377
- the abandoned suffix is hidden.
377
+ the abandoned suffix is hidden. When Railmux observes a running Codex session
378
+ advance to its rewind child, it also removes the abandoned terminal screen and
379
+ tmux scrollback before Codex redraws the live pane.
378
380
 
379
381
  Preview opens at the latest activity in `less`; large sessions are limited to
380
382
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -331,7 +331,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
331
331
  internal context and encrypted reasoning are hidden. Codex rewind lineages and
332
332
  Claude Code `parentUuid` branches are projected onto the provider's current
333
333
  conversation: the retained prefix and replacement suffix remain visible, while
334
- the abandoned suffix is hidden.
334
+ the abandoned suffix is hidden. When Railmux observes a running Codex session
335
+ advance to its rewind child, it also removes the abandoned terminal screen and
336
+ tmux scrollback before Codex redraws the live pane.
335
337
 
336
338
  Preview opens at the latest activity in `less`; large sessions are limited to
337
339
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -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
@@ -1074,7 +1074,7 @@ class TerminalSurface:
1074
1074
  self,
1075
1075
  requested: TerminalMode,
1076
1076
  ) -> TerminalMode:
1077
- """Mirror only input-affecting modes explicitly carried by protocol v14."""
1077
+ """Mirror only input-affecting modes explicitly carried by protocol v15."""
1078
1078
  disabled = self.terminal_modes & ~requested
1079
1079
  enabled = requested & ~self.terminal_modes
1080
1080
  controls: list[bytes] = []
@@ -2013,7 +2013,7 @@ def _finish_remote_attach(
2013
2013
  raise ProbeError("remote display helper failed before attaching")
2014
2014
 
2015
2015
  _stop_unstarted_remote(process)
2016
- # A current v14 helper holds the mutex only while registering its exact tmux
2016
+ # A current v15 helper holds the mutex only while registering its exact tmux
2017
2017
  # child. Give that ordinary race one fresh SSH process before presenting
2018
2018
  # the explicit legacy-lock takeover choice.
2019
2019
  time.sleep(_REMOTE_ATTACH_RETRY_DELAY)
@@ -207,7 +207,11 @@ class LocalHistoryView:
207
207
  restore_live = False
208
208
  for pane_id, viewport in tuple(self.viewports.items()):
209
209
  route = routes.get(pane_id)
210
- if route is None or not self._same_geometry(viewport.snapshot, route):
210
+ if (
211
+ route is None
212
+ or not self._same_geometry(viewport.snapshot, route)
213
+ or not self._history_source_matches(viewport.snapshot, route)
214
+ ):
211
215
  self.cancel_pane(pane_id)
212
216
  restore_live = True
213
217
  return HistoryAction(restore_live=restore_live)
@@ -217,10 +221,11 @@ class LocalHistoryView:
217
221
  left: HistorySnapshot,
218
222
  right: HistorySnapshot,
219
223
  ) -> bool:
220
- """Do not combine native, transcript, and undecided history."""
224
+ """Do not combine different providers or terminal generations."""
221
225
  return (
222
226
  left.transcript_backed == right.transcript_backed
223
227
  and left.history_choice_required == right.history_choice_required
228
+ and left.generation == right.generation
224
229
  )
225
230
 
226
231
  @staticmethod
@@ -813,7 +818,11 @@ class LocalHistoryView:
813
818
  ),
814
819
  None,
815
820
  )
816
- if route is None or not self._same_geometry(route, snapshot):
821
+ if (
822
+ route is None
823
+ or not self._same_geometry(route, snapshot)
824
+ or not self._history_source_matches(route, snapshot)
825
+ ):
817
826
  return HistoryAction()
818
827
  viewport = self.viewports.get(pending.pane_id)
819
828
  if viewport is None:
@@ -1,4 +1,4 @@
1
- """Private v14 framing for the coalesced full-window SSH display."""
1
+ """Private v15 framing for the coalesced full-window SSH display."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,9 +8,9 @@ from dataclasses import dataclass
8
8
  from enum import IntEnum, IntFlag
9
9
 
10
10
 
11
- DISPLAY_MAGIC = b"RMUXD14\x00"
12
- INPUT_MAGIC = b"RMUXK14\x00"
13
- PROTOCOL_VERSION = 14
11
+ DISPLAY_MAGIC = b"RMUXD15\x00"
12
+ INPUT_MAGIC = b"RMUXK15\x00"
13
+ PROTOCOL_VERSION = 15
14
14
  REMOTE_CONFIG_PROTOCOL = 1
15
15
  LENGTH_BYTES = 4
16
16
  REMOTE_HELLO_PREFIX = b"RAILMUX-REMOTE/1 "
@@ -28,10 +28,10 @@ MAX_HISTORY_PANES = 8
28
28
  MAX_CLIPBOARD_BYTES = 64 * 1024
29
29
  MAX_PATH_BYTES = 4096
30
30
  _UPDATE_METADATA = struct.Struct(">BIHHHHBHI")
31
- _HISTORY_METADATA = struct.Struct(">IIHHHHBI")
31
+ _HISTORY_METADATA = struct.Struct(">IIHHHHBQI")
32
32
  _HISTORY_REQUEST = struct.Struct(">IHHH")
33
33
  _HISTORY_BATCH_METADATA = struct.Struct(">II")
34
- _HISTORY_PANE_METADATA = struct.Struct(">IHHHHB")
34
+ _HISTORY_PANE_METADATA = struct.Struct(">IHHHHBQ")
35
35
  _PREFETCH_HISTORY_REQUEST = struct.Struct(">IH")
36
36
  _PATH_REQUEST = struct.Struct(">IIH")
37
37
  _PATH_RESULT = struct.Struct(">IBBH")
@@ -143,6 +143,7 @@ class HistorySnapshot:
143
143
  more_available: bool = False
144
144
  transcript_available: bool = False
145
145
  history_choice_required: bool = False
146
+ generation: int = 0
146
147
 
147
148
 
148
149
  @dataclass(frozen=True)
@@ -302,6 +303,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
302
303
  snapshot.more_available,
303
304
  snapshot.transcript_available,
304
305
  snapshot.history_choice_required,
306
+ snapshot.generation,
305
307
  )):
306
308
  raise ValueError("a rejected history response must be empty")
307
309
  pane_number = 0
@@ -317,6 +319,8 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
317
319
  raise ValueError("invalid history position")
318
320
  if len(snapshot.lines) < snapshot.height:
319
321
  raise ValueError("history snapshot is shorter than its viewport")
322
+ if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
323
+ raise ValueError("invalid history generation")
320
324
  raw_lines = _pack_history_lines(snapshot.lines)
321
325
  compressed = zlib.compress(raw_lines, level=3)
322
326
  metadata = _HISTORY_METADATA.pack(
@@ -327,6 +331,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
327
331
  snapshot.width,
328
332
  snapshot.height,
329
333
  _history_capabilities(snapshot),
334
+ snapshot.generation,
330
335
  len(raw_lines),
331
336
  )
332
337
  payload = bytes((int(OutputKind.HISTORY),)) + metadata + compressed
@@ -360,6 +365,8 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
360
365
  raise ValueError("invalid history position")
361
366
  if len(snapshot.lines) < snapshot.height:
362
367
  raise ValueError("history snapshot is shorter than its viewport")
368
+ if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
369
+ raise ValueError("invalid history generation")
363
370
  packed_lines = _pack_history_lines(snapshot.lines)
364
371
  pane_metadata = _HISTORY_PANE_METADATA.pack(
365
372
  pane_number,
@@ -368,6 +375,7 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
368
375
  snapshot.width,
369
376
  snapshot.height,
370
377
  _history_capabilities(snapshot),
378
+ snapshot.generation,
371
379
  )
372
380
  total += len(pane_metadata) + len(packed_lines)
373
381
  if total > MAX_SCREEN_BYTES:
@@ -611,6 +619,7 @@ class ServerMessageDecoder:
611
619
  width,
612
620
  height,
613
621
  raw_capabilities,
622
+ generation,
614
623
  raw_size,
615
624
  ) = (
616
625
  _HISTORY_METADATA.unpack(body[:_HISTORY_METADATA.size])
@@ -628,6 +637,7 @@ class ServerMessageDecoder:
628
637
  height,
629
638
  lines,
630
639
  raw_capabilities,
640
+ generation,
631
641
  )):
632
642
  raise ValueError("invalid rejected history response")
633
643
  pane_id = None
@@ -662,6 +672,7 @@ class ServerMessageDecoder:
662
672
  history_choice_required=bool(
663
673
  raw_capabilities & _HISTORY_CHOICE_REQUIRED
664
674
  ),
675
+ generation=generation,
665
676
  ))
666
677
  continue
667
678
  if output_kind is OutputKind.HISTORY_BATCH:
@@ -691,6 +702,7 @@ class ServerMessageDecoder:
691
702
  width,
692
703
  height,
693
704
  raw_capabilities,
705
+ generation,
694
706
  ) = _HISTORY_PANE_METADATA.unpack(
695
707
  raw[offset:offset + _HISTORY_PANE_METADATA.size]
696
708
  )
@@ -729,6 +741,7 @@ class ServerMessageDecoder:
729
741
  history_choice_required=bool(
730
742
  raw_capabilities & _HISTORY_CHOICE_REQUIRED
731
743
  ),
744
+ generation=generation,
732
745
  ))
733
746
  if offset != len(raw):
734
747
  raise ValueError("trailing history batch data")
@@ -843,7 +856,7 @@ class ServerMessageDecoder:
843
856
 
844
857
 
845
858
  class ScreenUpdateDecoder:
846
- """Compatibility view which ignores v14 control-response messages."""
859
+ """Compatibility view which ignores v15 control-response messages."""
847
860
 
848
861
  def __init__(self) -> None:
849
862
  self._decoder = ServerMessageDecoder()
@@ -45,6 +45,7 @@ from railmux import (
45
45
  __version__,
46
46
  local_open,
47
47
  restart_state,
48
+ tmux_ctl,
48
49
  tmux_health,
49
50
  tmux_server,
50
51
  )
@@ -403,6 +404,7 @@ class _PaneGeometry:
403
404
  transcript_source: str | None = None
404
405
  transcript_backed: bool = False
405
406
  claude_history_policy: str = "ask"
407
+ history_generation: int = 0
406
408
 
407
409
 
408
410
  @dataclass(frozen=True)
@@ -421,6 +423,24 @@ _INFERRED_TRANSCRIPT_LIMIT = 8
421
423
  _TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024
422
424
  _SESSION_BINDING_OPTION = "@railmux_binding_v1"
423
425
  _SWAP_OPTIONS = ("@railmux_swap_primary", "@railmux_swap_secondary")
426
+ _HISTORY_GENERATION_RE = re.compile(
427
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
428
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
429
+ )
430
+
431
+
432
+ def _history_generation(marker: str) -> int:
433
+ """Map one validated provider UUID to a bounded opaque wire epoch."""
434
+ if not _HISTORY_GENERATION_RE.fullmatch(marker):
435
+ return 0
436
+ return int.from_bytes(
437
+ hashlib.blake2b(
438
+ marker.lower().encode("ascii"),
439
+ digest_size=8,
440
+ person=b"railmux-history",
441
+ ).digest(),
442
+ "big",
443
+ )
424
444
 
425
445
 
426
446
  _ANSI_FG = {
@@ -834,6 +854,7 @@ def _list_agent_panes(
834
854
  "#{history_size} #{alternate_on} #{mouse_any_flag} "
835
855
  f"#{{{tmux_server.HISTORY_SOURCE_OPTION}}} "
836
856
  f"#{{{tmux_server.TRANSCRIPT_SOURCE_OPTION}}} "
857
+ f"#{{{tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION}}} "
837
858
  f"#{{{TOOL_PANE_OPTION}}}",
838
859
  ),
839
860
  stderr=subprocess.DEVNULL,
@@ -849,7 +870,7 @@ def _list_agent_panes(
849
870
  for raw_row in output.splitlines():
850
871
  fields = raw_row.split(" ")
851
872
  if (
852
- len(fields) != 16
873
+ len(fields) != 17
853
874
  or fields[0] != session_id
854
875
  or fields[2] not in ("0", "1")
855
876
  or fields[3] not in ("0", "1")
@@ -880,7 +901,7 @@ def _list_agent_panes(
880
901
  ):
881
902
  return ()
882
903
  seen.add(pane_id)
883
- if is_tool_pane_marker(fields[15], outer_session_id=session_id):
904
+ if is_tool_pane_marker(fields[16], outer_session_id=session_id):
884
905
  continue
885
906
  history_server = None
886
907
  history_pane_id = None
@@ -931,6 +952,7 @@ def _list_agent_panes(
931
952
  transcript_source=transcript_marker,
932
953
  transcript_backed=transcript_backed,
933
954
  claude_history_policy=claude_history_policy,
955
+ history_generation=_history_generation(fields[15]),
934
956
  ),
935
957
  )
936
958
  )
@@ -1376,6 +1398,7 @@ def _capture_pane_history(
1376
1398
  else pane.history_size + pane.height > len(lines)
1377
1399
  )
1378
1400
  ),
1401
+ generation=pane.history_generation,
1379
1402
  )
1380
1403
 
1381
1404
 
@@ -1916,7 +1939,7 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
1916
1939
 
1917
1940
 
1918
1941
  def terminal_modes_for_screen(screen: object) -> TerminalMode:
1919
- """Project pyte's private-mode set onto the bounded v14 wire allowlist."""
1942
+ """Project pyte's private-mode set onto the bounded v15 wire allowlist."""
1920
1943
  terminal_modes = TerminalMode.NONE
1921
1944
  if 2004 << 5 in screen.mode:
1922
1945
  terminal_modes |= TerminalMode.BRACKETED_PASTE
@@ -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}),