railmux 0.4.1.dev4__tar.gz → 0.4.1.dev6__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 (205) hide show
  1. {railmux-0.4.1.dev4/src/railmux.egg-info → railmux-0.4.1.dev6}/PKG-INFO +1 -1
  2. railmux-0.4.1.dev6/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fast_display_input.py +149 -28
  4. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fast_display_server.py +3 -2
  5. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/terminal_screen.py +167 -1
  6. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tmux_ctl.py +33 -4
  7. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/app.py +34 -8
  8. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6/src/railmux.egg-info}/PKG-INFO +1 -1
  9. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_selection.py +60 -0
  10. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_server.py +64 -0
  11. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fullscreen_binding.py +22 -1
  12. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_layout_profile.py +1 -0
  13. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_ctl.py +13 -1
  14. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_integration.py +21 -9
  15. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_status.py +28 -3
  16. railmux-0.4.1.dev4/src/railmux/__init__.py +0 -1
  17. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/LICENSE +0 -0
  18. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/MANIFEST.in +0 -0
  19. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/README.md +0 -0
  20. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/pyproject.toml +0 -0
  21. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/setup.cfg +0 -0
  22. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/__main__.py +0 -0
  23. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/atomic_file.py +0 -0
  24. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/background_index.py +0 -0
  25. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/cli.py +0 -0
  26. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/codex_index.py +0 -0
  27. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/config.py +0 -0
  28. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/config_cli.py +0 -0
  29. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/diagnostic_contract.py +0 -0
  30. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/diagnostics.py +0 -0
  31. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/discovery.py +0 -0
  32. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/display_transport.py +0 -0
  33. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/entrypoint.py +0 -0
  34. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fast_display_client.py +0 -0
  35. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fast_display_history.py +0 -0
  36. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fast_display_protocol.py +0 -0
  37. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/favorites.py +0 -0
  38. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/fuzzy.py +0 -0
  39. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/help_workspace.py +0 -0
  40. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/launcher.py +0 -0
  41. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/legacy_sessions.py +0 -0
  42. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/local_clipboard.py +0 -0
  43. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/local_open.py +0 -0
  44. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/models.py +0 -0
  45. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/modes.py +0 -0
  46. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/mouse_manager.py +0 -0
  47. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/orphan_marker.py +0 -0
  48. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/pane_surface.py +0 -0
  49. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/path_codec.py +0 -0
  50. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/preview_pager.py +0 -0
  51. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/project_favorites.py +0 -0
  52. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/provider_paths.py +0 -0
  53. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/release_version.py +0 -0
  54. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/remote_config.py +0 -0
  55. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/renames.py +0 -0
  56. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/restart_state.py +0 -0
  57. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/runtime_config.py +0 -0
  58. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/scroll_agent.py +0 -0
  59. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/scroll_manager.py +0 -0
  60. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/selection_isolation.py +0 -0
  61. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/self_update.py +0 -0
  62. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/session_cache.py +0 -0
  63. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/session_index.py +0 -0
  64. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/session_lease.py +0 -0
  65. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/setting_contracts.py +0 -0
  66. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/settings.py +0 -0
  67. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ssh_args.py +0 -0
  68. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ssh_compat.py +0 -0
  69. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ssh_display_diagnostics.py +0 -0
  70. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ssh_doctor.py +0 -0
  71. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ssh_preflight.py +0 -0
  72. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/system_deps.py +0 -0
  73. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/terminal_status.py +0 -0
  74. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tmux_binding_manager.py +0 -0
  75. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tmux_capabilities.py +0 -0
  76. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tmux_health.py +0 -0
  77. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tmux_server.py +0 -0
  78. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/tool_panes.py +0 -0
  79. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/transcript.py +0 -0
  80. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/__init__.py +0 -0
  81. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/_widgets.py +0 -0
  82. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/keymap.py +0 -0
  83. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/modals.py +0 -0
  84. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/projects_pane.py +0 -0
  85. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/running_pane.py +0 -0
  86. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/sessions_pane.py +0 -0
  87. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/sidebar.py +0 -0
  88. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/statusbar.py +0 -0
  89. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/ui/workspace.py +0 -0
  90. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_attach_relay.py +0 -0
  91. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_bootstrap.py +0 -0
  92. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_install_log.py +0 -0
  93. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_msys2.py +0 -0
  94. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_pacman.py +0 -0
  95. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_paths.py +0 -0
  96. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_tmux_lifecycle.py +0 -0
  97. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux/windows_ui_transition.py +0 -0
  98. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux.egg-info/SOURCES.txt +0 -0
  99. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux.egg-info/dependency_links.txt +0 -0
  100. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux.egg-info/entry_points.txt +0 -0
  101. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux.egg-info/requires.txt +0 -0
  102. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/src/railmux.egg-info/top_level.txt +0 -0
  103. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/app_test_harness.py +0 -0
  104. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_agent_workspace.py +0 -0
  105. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_app_codex_placeholder.py +0 -0
  106. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_app_quit.py +0 -0
  107. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_app_restart_restore.py +0 -0
  108. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_app_running_recovery.py +0 -0
  109. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_atomic_file.py +0 -0
  110. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_background_index.py +0 -0
  111. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_cli.py +0 -0
  112. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_code_map.py +0 -0
  113. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_codex_delete.py +0 -0
  114. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_codex_index.py +0 -0
  115. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_codex_lineage.py +0 -0
  116. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_codex_transcript.py +0 -0
  117. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_config.py +0 -0
  118. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_config_cli.py +0 -0
  119. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_denested_transport_benchmark.py +0 -0
  120. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_diagnostics.py +0 -0
  121. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_discovery.py +0 -0
  122. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_display_transport.py +0 -0
  123. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_bootstrap.py +0 -0
  124. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_history.py +0 -0
  125. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_protocol.py +0 -0
  126. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fast_display_terminal.py +0 -0
  127. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_focus_highlight.py +0 -0
  128. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_fuzzy.py +0 -0
  129. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_help_agent.py +0 -0
  130. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_help_workspace.py +0 -0
  131. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_history_cleanup.py +0 -0
  132. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_keymap.py +0 -0
  133. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_launcher.py +0 -0
  134. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_legacy_sessions.py +0 -0
  135. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_local_clipboard.py +0 -0
  136. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_local_open.py +0 -0
  137. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_modals.py +0 -0
  138. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_mode_selection.py +0 -0
  139. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_modes.py +0 -0
  140. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_mouse_manager.py +0 -0
  141. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_new_project.py +0 -0
  142. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_options.py +0 -0
  143. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_orphan_recovery.py +0 -0
  144. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_pane_surface.py +0 -0
  145. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_path_codec.py +0 -0
  146. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_preview_pager.py +0 -0
  147. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_project_favorites.py +0 -0
  148. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_projects_pane.py +0 -0
  149. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_provider_paths.py +0 -0
  150. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_release_notes.py +0 -0
  151. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_release_version.py +0 -0
  152. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_release_workflow.py +0 -0
  153. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_remote_config.py +0 -0
  154. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_renames.py +0 -0
  155. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_restart_state.py +0 -0
  156. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_running_pane.py +0 -0
  157. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_running_sort.py +0 -0
  158. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_runtime_config.py +0 -0
  159. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_scroll_agent.py +0 -0
  160. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_scroll_manager.py +0 -0
  161. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_selection_isolation.py +0 -0
  162. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_self_update.py +0 -0
  163. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_session_cache.py +0 -0
  164. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_session_index.py +0 -0
  165. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_session_lease.py +0 -0
  166. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_sessions_pane.py +0 -0
  167. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_setting_contracts.py +0 -0
  168. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_settings.py +0 -0
  169. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_sidebar.py +0 -0
  170. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_ssh_args.py +0 -0
  171. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_ssh_compat.py +0 -0
  172. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_ssh_display_benchmark.py +0 -0
  173. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_ssh_display_diagnostics.py +0 -0
  174. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_ssh_doctor.py +0 -0
  175. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_status.py +0 -0
  176. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_statusbar.py +0 -0
  177. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_system_deps.py +0 -0
  178. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_terminal_size.py +0 -0
  179. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_terminal_status.py +0 -0
  180. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_termux_local_touch.py +0 -0
  181. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_binding_manager.py +0 -0
  182. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_capabilities.py +0 -0
  183. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_health.py +0 -0
  184. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tmux_server.py +0 -0
  185. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_tool_panes.py +0 -0
  186. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_transcript.py +0 -0
  187. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_utf8_state.py +0 -0
  188. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_web_demo_recorder.py +0 -0
  189. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_wheel_smoke.py +0 -0
  190. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_widgets.py +0 -0
  191. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_attach_relay.py +0 -0
  192. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_bootstrap.py +0 -0
  193. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_install_log.py +0 -0
  194. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_msys2.py +0 -0
  195. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_pacman.py +0 -0
  196. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_paths.py +0 -0
  197. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_synchronized_screen.py +0 -0
  198. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_tmux_lifecycle.py +0 -0
  199. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_ui_transition.py +0 -0
  200. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_windows_wrapper_contract.py +0 -0
  201. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tests/test_yolo_prompt.py +0 -0
  202. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tools/denested_transport_benchmark.py +0 -0
  203. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tools/release_notes.py +0 -0
  204. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tools/wheel_smoke.py +0 -0
  205. {railmux-0.4.1.dev4 → railmux-0.4.1.dev6}/tools/windows_archive_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.1.dev4
3
+ Version: 0.4.1.dev6
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.1.dev6"
@@ -22,6 +22,9 @@ from railmux.fast_display_protocol import (
22
22
 
23
23
  _SGR_MOUSE_PREFIX = b"\x1b[<"
24
24
  _SGR_STYLE_RE = re.compile(rb"\x1b\[[0-9;]*m")
25
+ _OSC8_LINK_RE = re.compile(
26
+ rb"\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)"
27
+ )
25
28
  _PAGE_UP = b"\x1b[5~"
26
29
  _PAGE_DOWN = b"\x1b[6~"
27
30
  _BRACKETED_PASTE_BEGIN = b"\x1b[200~"
@@ -505,27 +508,62 @@ def _display_width(character: str) -> int:
505
508
  return max(0, pyte.screens.wcwidth(character))
506
509
 
507
510
 
508
- def _plain_display_cells(line: bytes, width: int) -> tuple[str | None, ...]:
509
- """Decode one server-rendered SGR row into bounded display cells."""
510
- plain = _SGR_STYLE_RE.sub(b"", line).decode("utf-8", errors="replace")
511
+ def _validated_osc8_url(value: bytes) -> str | None:
512
+ if not value or len(value) > 8192:
513
+ return None
514
+ try:
515
+ decoded = value.decode("utf-8")
516
+ parsed = urlsplit(decoded)
517
+ hostname = parsed.hostname
518
+ except (UnicodeDecodeError, ValueError):
519
+ return None
520
+ if (
521
+ parsed.scheme.lower() not in ("http", "https")
522
+ or hostname is None
523
+ or any(ord(character) < 0x20 or character == "\x7f" for character in decoded)
524
+ ):
525
+ return None
526
+ return decoded
527
+
528
+
529
+ def _plain_display_row(
530
+ line: bytes,
531
+ width: int,
532
+ ) -> tuple[tuple[str | None, ...], tuple[str | None, ...]]:
533
+ """Decode one rendered row and retain bounded OSC 8 targets per cell."""
511
534
  cells: list[str | None] = [" "] * width
535
+ hyperlinks: list[str | None] = [None] * width
512
536
  column = 0
513
- for character in plain:
514
- cell_width = _display_width(character)
515
- if cell_width == 0:
516
- previous = column - 1
517
- while previous >= 0 and cells[previous] is None:
518
- previous -= 1
519
- if previous >= 0:
520
- cells[previous] = (cells[previous] or "") + character
521
- continue
522
- if column >= width or column + cell_width > width:
523
- break
524
- cells[column] = character
525
- for continuation in range(1, cell_width):
526
- cells[column + continuation] = None
527
- column += cell_width
528
- return tuple(cells)
537
+ active_hyperlink: str | None = None
538
+
539
+ def append_visible(data: bytes) -> None:
540
+ nonlocal column
541
+ plain = _SGR_STYLE_RE.sub(b"", data).decode("utf-8", errors="replace")
542
+ for character in plain:
543
+ cell_width = _display_width(character)
544
+ if cell_width == 0:
545
+ previous = column - 1
546
+ while previous >= 0 and cells[previous] is None:
547
+ previous -= 1
548
+ if previous >= 0:
549
+ cells[previous] = (cells[previous] or "") + character
550
+ continue
551
+ if column >= width or column + cell_width > width:
552
+ return
553
+ cells[column] = character
554
+ hyperlinks[column] = active_hyperlink
555
+ for continuation in range(1, cell_width):
556
+ cells[column + continuation] = None
557
+ hyperlinks[column + continuation] = active_hyperlink
558
+ column += cell_width
559
+
560
+ offset = 0
561
+ for match in _OSC8_LINK_RE.finditer(line):
562
+ append_visible(line[offset : match.start()])
563
+ active_hyperlink = _validated_osc8_url(match.group(1))
564
+ offset = match.end()
565
+ append_visible(line[offset:])
566
+ return tuple(cells), tuple(hyperlinks)
529
567
 
530
568
 
531
569
  _CLICK_TOKEN_RE = re.compile(r"""[^\s<>"'`]+""")
@@ -719,23 +757,97 @@ def click_target_at(
719
757
  return None
720
758
 
721
759
 
722
- def _pane_cells(
760
+ def _pane_rows(
723
761
  source: SelectionSource,
724
762
  *,
725
763
  start: int = 0,
726
764
  end: int | None = None,
727
- ) -> tuple[tuple[str | None, ...], ...]:
728
- """Decode pane-local cells from either full-screen or history rows."""
765
+ ) -> tuple[
766
+ tuple[tuple[str | None, ...], ...],
767
+ tuple[tuple[str | None, ...], ...],
768
+ ]:
769
+ """Decode pane-local cells and OSC 8 targets in one bounded pass."""
729
770
  route = source.route
730
771
  if end is None:
731
772
  end = route.height
732
773
  decode_width = source.row_x_offset + route.width
733
- decoded: list[tuple[str | None, ...]] = []
774
+ decoded_cells: list[tuple[str | None, ...]] = []
775
+ decoded_hyperlinks: list[tuple[str | None, ...]] = []
734
776
  for index in range(start, min(end, route.height)):
735
777
  line = source.rows[index] if index < len(source.rows) else b""
736
- cells = _plain_display_cells(line, decode_width)
737
- decoded.append(cells[source.row_x_offset : source.row_x_offset + route.width])
738
- return tuple(decoded)
778
+ cells, hyperlinks = _plain_display_row(line, decode_width)
779
+ span = slice(source.row_x_offset, source.row_x_offset + route.width)
780
+ decoded_cells.append(cells[span])
781
+ decoded_hyperlinks.append(hyperlinks[span])
782
+ return tuple(decoded_cells), tuple(decoded_hyperlinks)
783
+
784
+
785
+ def _pane_cells(
786
+ source: SelectionSource,
787
+ *,
788
+ start: int = 0,
789
+ end: int | None = None,
790
+ ) -> tuple[tuple[str | None, ...], ...]:
791
+ """Decode pane-local cells from either full-screen or history rows."""
792
+ return _pane_rows(source, start=start, end=end)[0]
793
+
794
+
795
+ def _osc8_target(
796
+ rows: tuple[tuple[str | None, ...], ...],
797
+ hyperlinks: tuple[tuple[str | None, ...], ...],
798
+ row: int,
799
+ column: int,
800
+ *,
801
+ route: HistorySnapshot,
802
+ ) -> ClickTarget | None:
803
+ """Resolve one exact OSC 8 target without reconstructing its label."""
804
+ if not (0 <= row < len(rows) and 0 <= row < len(hyperlinks)):
805
+ return None
806
+ if not 0 <= column < len(hyperlinks[row]):
807
+ return None
808
+ value = hyperlinks[row][column]
809
+ if value is None:
810
+ return None
811
+
812
+ segments: list[SelectionSegment] = []
813
+ first = row
814
+ while first > 0 and value in hyperlinks[first - 1]:
815
+ first -= 1
816
+ last = row
817
+ while last + 1 < len(hyperlinks) and value in hyperlinks[last + 1]:
818
+ last += 1
819
+ for row_index in range(first, last + 1):
820
+ link_row = hyperlinks[row_index]
821
+ cell_row = rows[row_index]
822
+ run_start: int | None = None
823
+ for cell_index in range(len(link_row) + 1):
824
+ linked = cell_index < len(link_row) and link_row[cell_index] == value
825
+ if linked and run_start is None:
826
+ run_start = cell_index
827
+ if not linked and run_start is not None:
828
+ text = "".join(
829
+ cell or "" for cell in cell_row[run_start:cell_index]
830
+ ).encode("utf-8")
831
+ if text:
832
+ segments.append(
833
+ (
834
+ route.y + row_index,
835
+ route.x + run_start,
836
+ text,
837
+ )
838
+ )
839
+ run_start = None
840
+ if not segments:
841
+ return None
842
+ return ClickTarget(
843
+ "url",
844
+ value,
845
+ route.pane_id or "",
846
+ highlight_row=segments[0][0],
847
+ highlight_column=segments[0][1],
848
+ highlight_text=segments[0][2],
849
+ highlight_segments=tuple(segments),
850
+ )
739
851
 
740
852
 
741
853
  def _rows_are_wrapped(
@@ -1124,10 +1236,14 @@ def _target_in_rows(
1124
1236
  column: int,
1125
1237
  *,
1126
1238
  route: HistorySnapshot,
1239
+ hyperlinks: tuple[tuple[str | None, ...], ...] = (),
1127
1240
  ) -> ClickTarget | None:
1128
1241
  """Resolve a semantic target across one bounded visual-wrap chain."""
1129
1242
  if not 0 <= row < len(rows) or route.width <= 0:
1130
1243
  return None
1244
+ exact = _osc8_target(rows, hyperlinks, row, column, route=route)
1245
+ if exact is not None:
1246
+ return exact
1131
1247
  decorated = _decorated_tool_target(
1132
1248
  rows,
1133
1249
  row,
@@ -1200,6 +1316,7 @@ class LocalTextSelection:
1200
1316
  self._press: SgrMouseEvent | None = None
1201
1317
  self._route: HistorySnapshot | None = None
1202
1318
  self._rows: tuple[tuple[str | None, ...], ...] = ()
1319
+ self._hyperlinks: tuple[tuple[str | None, ...], ...] = ()
1203
1320
  self._anchor: tuple[int, int] | None = None
1204
1321
  self._head: tuple[int, int] | None = None
1205
1322
  self._active = False
@@ -1221,6 +1338,7 @@ class LocalTextSelection:
1221
1338
  self._press = None
1222
1339
  self._route = None
1223
1340
  self._rows = ()
1341
+ self._hyperlinks = ()
1224
1342
  self._anchor = None
1225
1343
  self._head = None
1226
1344
  self._active = False
@@ -1240,8 +1358,9 @@ class LocalTextSelection:
1240
1358
  return None
1241
1359
  start = max(0, row - 7)
1242
1360
  end = min(source.route.height, row + 8)
1361
+ rows, hyperlinks = _pane_rows(source, start=start, end=end)
1243
1362
  return _target_in_rows(
1244
- _pane_cells(source, start=start, end=end),
1363
+ rows,
1245
1364
  row - start,
1246
1365
  column,
1247
1366
  route=replace(
@@ -1249,6 +1368,7 @@ class LocalTextSelection:
1249
1368
  y=source.route.y + start,
1250
1369
  height=end - start,
1251
1370
  ),
1371
+ hyperlinks=hyperlinks,
1252
1372
  )
1253
1373
 
1254
1374
  def hover(
@@ -1352,7 +1472,7 @@ class LocalTextSelection:
1352
1472
  route = source.route
1353
1473
  self._press = event
1354
1474
  self._route = route
1355
- self._rows = _pane_cells(source)
1475
+ self._rows, self._hyperlinks = _pane_rows(source)
1356
1476
  self._anchor = self._point(event, route)
1357
1477
  self._head = self._anchor
1358
1478
  self._active = False
@@ -1385,6 +1505,7 @@ class LocalTextSelection:
1385
1505
  self._anchor[1],
1386
1506
  self._anchor[0],
1387
1507
  route=self._route,
1508
+ hyperlinks=self._hyperlinks,
1388
1509
  )
1389
1510
  if (
1390
1511
  self._semantic_open
@@ -80,6 +80,7 @@ from railmux.fast_display_protocol import (
80
80
  )
81
81
  from railmux.fast_display_history import HistoryCaptureJob, HistoryCaptureWorker
82
82
  from railmux.terminal_screen import (
83
+ HyperlinkTrackingStream,
83
84
  Osc52ClipboardDecoder as _Osc52ClipboardDecoder,
84
85
  ScreenState as _ScreenState,
85
86
  build_screen_update,
@@ -1226,7 +1227,7 @@ def _render_history_lines(
1226
1227
  result independently paintable for the local history overlay.
1227
1228
  """
1228
1229
  screen = pyte.Screen(width, 1)
1229
- stream = pyte.ByteStream(screen)
1230
+ stream = HyperlinkTrackingStream(screen, pyte.ByteStream(screen))
1230
1231
  rendered: list[bytes] = []
1231
1232
  for line in lines:
1232
1233
  screen.buffer[0].clear()
@@ -2206,7 +2207,7 @@ def _serve_attached(
2206
2207
  master_fd: int,
2207
2208
  ) -> int:
2208
2209
  screen = pyte.DiffScreen(width, height)
2209
- stream = pyte.ByteStream(screen)
2210
+ stream = HyperlinkTrackingStream(screen, pyte.ByteStream(screen))
2210
2211
  input_decoder = InputFrameDecoder()
2211
2212
  stdin_fd = sys.stdin.buffer.fileno()
2212
2213
  stdout_fd = sys.stdout.buffer.fileno()
@@ -14,6 +14,8 @@ import re
14
14
  from dataclasses import dataclass
15
15
  from functools import lru_cache
16
16
  from types import SimpleNamespace
17
+ from typing import NamedTuple
18
+ from urllib.parse import urlsplit
17
19
 
18
20
  from railmux.fast_display_protocol import (
19
21
  MAX_CLIPBOARD_BYTES,
@@ -65,6 +67,121 @@ _ANSI_BG = {
65
67
  }
66
68
  _OSC52_PREFIX = b"\033]52;"
67
69
  _OSC52_MAX_ENCODED = ((MAX_CLIPBOARD_BYTES + 2) // 3) * 4
70
+ _OSC8_PREFIX = b"\033]8;"
71
+ _OSC8_MAX_BYTES = 16 * 1024
72
+ _MAX_SCREEN_HYPERLINKS = 256
73
+
74
+
75
+ class _HyperlinkChar(NamedTuple):
76
+ """A pyte-compatible cell with one bounded OSC 8 target."""
77
+
78
+ data: str
79
+ fg: str = "default"
80
+ bg: str = "default"
81
+ bold: bool = False
82
+ italics: bool = False
83
+ underscore: bool = False
84
+ strikethrough: bool = False
85
+ reverse: bool = False
86
+ blink: bool = False
87
+ hyperlink: str | None = None
88
+
89
+
90
+ def _hyperlink_char(char: object, hyperlink: str | None) -> _HyperlinkChar:
91
+ return _HyperlinkChar(
92
+ data=char.data,
93
+ fg=char.fg,
94
+ bg=char.bg,
95
+ bold=char.bold,
96
+ italics=char.italics,
97
+ underscore=char.underscore,
98
+ strikethrough=char.strikethrough,
99
+ reverse=char.reverse,
100
+ blink=char.blink,
101
+ hyperlink=hyperlink,
102
+ )
103
+
104
+
105
+ def _safe_hyperlink(value: bytes) -> str | None:
106
+ if not value or len(value) > 8192:
107
+ return None
108
+ try:
109
+ decoded = value.decode("utf-8")
110
+ except UnicodeDecodeError:
111
+ return None
112
+ if any(ord(character) < 0x20 or character == "\x7f" for character in decoded):
113
+ return None
114
+ try:
115
+ parsed = urlsplit(decoded)
116
+ hostname = parsed.hostname
117
+ except ValueError:
118
+ return None
119
+ if parsed.scheme.lower() not in ("http", "https") or hostname is None:
120
+ return None
121
+ return decoded
122
+
123
+
124
+ class HyperlinkTrackingStream:
125
+ """Preserve bounded HTTP(S) OSC 8 targets in pyte screen cells.
126
+
127
+ pyte intentionally ignores OSC 8. Railmux needs the target independently
128
+ from its often-wrapped display label, so this wrapper consumes only OSC 8,
129
+ updates the extended screen's cell attribute, and passes every other byte
130
+ through unchanged. The parser is incremental and bounded because PTY
131
+ reads may split either terminator.
132
+ """
133
+
134
+ def __init__(self, screen: object, stream: object) -> None:
135
+ self.screen = screen
136
+ self.stream = stream
137
+ self._buffer = bytearray()
138
+
139
+ def _feed_visible(self, data: bytes) -> None:
140
+ if data:
141
+ self.stream.feed(data)
142
+
143
+ def feed(self, data: bytes) -> None:
144
+ if not data:
145
+ return
146
+ self._buffer.extend(data)
147
+ while self._buffer:
148
+ start = self._buffer.find(_OSC8_PREFIX)
149
+ if start < 0:
150
+ keep = 0
151
+ maximum = min(len(self._buffer), len(_OSC8_PREFIX) - 1)
152
+ for length in range(maximum, 0, -1):
153
+ if _OSC8_PREFIX.startswith(self._buffer[-length:]):
154
+ keep = length
155
+ break
156
+ visible_end = len(self._buffer) - keep
157
+ self._feed_visible(bytes(self._buffer[:visible_end]))
158
+ del self._buffer[:visible_end]
159
+ return
160
+ if start:
161
+ self._feed_visible(bytes(self._buffer[:start]))
162
+ del self._buffer[:start]
163
+ bel = self._buffer.find(b"\007", len(_OSC8_PREFIX))
164
+ st = self._buffer.find(b"\033\\", len(_OSC8_PREFIX))
165
+ endings = tuple(
166
+ (position, length)
167
+ for position, length in ((bel, 1), (st, 2))
168
+ if position >= 0
169
+ )
170
+ if not endings:
171
+ if len(self._buffer) <= _OSC8_MAX_BYTES:
172
+ return
173
+ # An unterminated lookalike is ordinary terminal output. Feed
174
+ # one byte and resume scanning without retaining an unbounded
175
+ # attacker-controlled prefix.
176
+ self._feed_visible(bytes(self._buffer[:1]))
177
+ del self._buffer[:1]
178
+ continue
179
+ end, terminator_length = min(endings)
180
+ body = bytes(self._buffer[len(_OSC8_PREFIX) : end])
181
+ del self._buffer[: end + terminator_length]
182
+ _params, separator, target = body.partition(b";")
183
+ hyperlink = _safe_hyperlink(target) if separator else None
184
+ self.screen.set_hyperlink(hyperlink)
68
185
 
69
186
 
70
187
  class Osc52ClipboardDecoder:
@@ -123,9 +240,29 @@ class _ExtendedScreenMixin:
123
240
  _last_graphic_character = ""
124
241
  _character_width = staticmethod(lambda _character: 1)
125
242
 
243
+ @property
244
+ def default_char(self) -> _HyperlinkChar:
245
+ return _hyperlink_char(super().default_char, None)
246
+
126
247
  def reset(self) -> None:
127
248
  self._last_graphic_character = ""
249
+ self._active_hyperlink: str | None = None
250
+ self._known_hyperlinks: dict[str, str] = {}
128
251
  super().reset()
252
+ self.cursor.attrs = _hyperlink_char(self.cursor.attrs, None)
253
+
254
+ def set_hyperlink(self, hyperlink: str | None) -> None:
255
+ if hyperlink is not None:
256
+ known = self._known_hyperlinks.get(hyperlink)
257
+ if known is None:
258
+ if len(self._known_hyperlinks) >= _MAX_SCREEN_HYPERLINKS:
259
+ hyperlink = None
260
+ else:
261
+ self._known_hyperlinks[hyperlink] = hyperlink
262
+ else:
263
+ hyperlink = known
264
+ self._active_hyperlink = hyperlink
265
+ self.cursor.attrs = _hyperlink_char(self.cursor.attrs, hyperlink)
129
266
 
130
267
  def draw(self, data: str) -> None:
131
268
  super().draw(data)
@@ -174,6 +311,10 @@ class _ExtendedScreenMixin:
174
311
  def select_graphic_rendition(self, *attrs: int) -> None:
175
312
  """Retain 256-colour indices instead of baking in pyte's palette."""
176
313
  super().select_graphic_rendition(*attrs)
314
+ self.cursor.attrs = _hyperlink_char(
315
+ self.cursor.attrs,
316
+ self._active_hyperlink,
317
+ )
177
318
  values = list(attrs)
178
319
  indexed: dict[str, int] = {}
179
320
  index = 0
@@ -209,6 +350,14 @@ class _ExtendedScreenMixin:
209
350
  **{key: f"ansi256:{colour}" for key, colour in indexed.items()}
210
351
  )
211
352
 
353
+ def restore_cursor(self) -> None:
354
+ super().restore_cursor()
355
+ # OSC 8 state is not part of DECSC/DECRC cursor attributes.
356
+ self.cursor.attrs = _hyperlink_char(
357
+ self.cursor.attrs,
358
+ self._active_hyperlink,
359
+ )
360
+
212
361
 
213
362
  @lru_cache(maxsize=4)
214
363
  def _build_extended_pyte(pyte: object) -> object:
@@ -333,6 +482,7 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
333
482
  for row_index in range(screen.lines):
334
483
  rendered = [b"\033[0m"]
335
484
  previous_style: tuple[object, ...] | None = None
485
+ previous_hyperlink: str | None = None
336
486
  row = screen.buffer[row_index]
337
487
  rendered_columns = _last_rendered_column(row, screen.columns)
338
488
  continuation_cells = 0
@@ -346,6 +496,17 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
346
496
  continuation_cells = 0
347
497
  continuation_data = ""
348
498
  char = row[column]
499
+ hyperlink = getattr(char, "hyperlink", None)
500
+ if hyperlink != previous_hyperlink:
501
+ if previous_hyperlink is not None:
502
+ rendered.append(b"\033]8;;\033\\")
503
+ if hyperlink is not None:
504
+ rendered.append(
505
+ b"\033]8;;"
506
+ + hyperlink.encode("utf-8", errors="strict")
507
+ + b"\033\\"
508
+ )
509
+ previous_hyperlink = hyperlink
349
510
  style = _style_key(char)
350
511
  if style != previous_style:
351
512
  rendered.append(_style(char))
@@ -366,6 +527,8 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
366
527
  )
367
528
  continuation_cells = max(0, cell_width - 1)
368
529
  continuation_data = char.data
530
+ if previous_hyperlink is not None:
531
+ rendered.append(b"\033]8;;\033\\")
369
532
  rendered.append(b"\033[0m")
370
533
  rendered_rows.append(b"".join(rendered))
371
534
  return tuple(rendered_rows)
@@ -465,7 +628,10 @@ class ScreenProducer:
465
628
  self.width = width
466
629
  self.height = height
467
630
  self.screen = self.pyte.DiffScreen(width, height)
468
- self.stream = self.pyte.ByteStream(self.screen)
631
+ self.stream = HyperlinkTrackingStream(
632
+ self.screen,
633
+ self.pyte.ByteStream(self.screen),
634
+ )
469
635
  self._clipboard = Osc52ClipboardDecoder()
470
636
  self._clipboard_ready: list[bytes] = []
471
637
  self.delivered: ScreenState | None = None
@@ -2046,6 +2046,9 @@ _ROOT_TERMUX_TAP_MARKER = "railmux-termux-tap-v1"
2046
2046
  STATUS_ACTION_MODE = "railmux-mode"
2047
2047
  STATUS_ACTION_LAYOUT = "railmux-layout"
2048
2048
  STATUS_ACTION_COPY = "railmux-copy"
2049
+ STATUS_ACTION_PAGE_SIDEBAR = "railmux-page-r"
2050
+ STATUS_ACTION_PAGE_PRIMARY = "railmux-page-1"
2051
+ STATUS_ACTION_PAGE_SECONDARY = "railmux-page-2"
2049
2052
  _ROOT_STATUS_ACTION_KEYS = {
2050
2053
  STATUS_ACTION_MODE: "MouseDown1Control0",
2051
2054
  STATUS_ACTION_LAYOUT: "MouseDown1Control1",
@@ -2059,6 +2062,9 @@ _STATUS_ACTIONS = frozenset({
2059
2062
  STATUS_ACTION_MODE,
2060
2063
  STATUS_ACTION_LAYOUT,
2061
2064
  STATUS_ACTION_COPY,
2065
+ STATUS_ACTION_PAGE_SIDEBAR,
2066
+ STATUS_ACTION_PAGE_PRIMARY,
2067
+ STATUS_ACTION_PAGE_SECONDARY,
2062
2068
  })
2063
2069
  _PREFIX_TARGET_KEY = "Tab"
2064
2070
  _PREFIX_TARGET_MARKER = "railmux-target-toggle-v1"
@@ -2085,6 +2091,15 @@ RAILMUX_HISTORY_RESTORE_KEYS = {
2085
2091
  "primary": ("F23", "\x1b[42~"),
2086
2092
  "secondary": ("F24", "\x1b[43~"),
2087
2093
  }
2094
+ # Compact status clicks must reach the controller before pane focus changes so
2095
+ # it can park the visible provider, switch the zoomed page, and resume the new
2096
+ # provider as one transaction. tmux names keys only through F12, therefore
2097
+ # these private routes use canonical xterm sequences in literal mode.
2098
+ RAILMUX_COMPACT_PAGE_KEYS = {
2099
+ STATUS_ACTION_PAGE_SIDEBAR: ("F13", "\x1b[25~"),
2100
+ STATUS_ACTION_PAGE_PRIMARY: ("F14", "\x1b[26~"),
2101
+ STATUS_ACTION_PAGE_SECONDARY: ("F15", "\x1b[28~"),
2102
+ }
2088
2103
  RAILMUX_SELECTION_KEY_OPTION = "@railmux_selection_key"
2089
2104
  RAILMUX_SELECTION_PEER_OPTION = "@railmux_selection_peer"
2090
2105
  RAILMUX_SELECTION_FROZEN_OPTION = "@railmux_selection_frozen_by"
@@ -2124,7 +2139,9 @@ def status_pane_range(pane_id: str, content: str) -> str:
2124
2139
  # A pane range sets ``mouse_status_range`` to only the literal ``pane``;
2125
2140
  # ``=`` still resolves to the pane geometrically below the status cell,
2126
2141
  # not to X. A user range preserves the strict pane ID as its argument, so
2127
- # the managed binding can select the intended compact page explicitly.
2142
+ # the managed binding can select a pane-targeted status control explicitly.
2143
+ # Compact page navigation uses controller actions instead so parking,
2144
+ # resuming, and zooming remain one ordered transaction.
2128
2145
  #
2129
2146
  # Status strings also pass through tmux's strftime expansion. Escape the
2130
2147
  # pane-ID percent sign so IDs such as ``%76`` are not parsed as padding
@@ -2149,8 +2166,8 @@ def status_action_range(action: str, content: str) -> str:
2149
2166
  # keys avoids routing fixed actions through the generic user-range
2150
2167
  # dispatcher and provides deterministic bindings on the 3.7b version
2151
2168
  # bundled by the managed Windows runtime.
2152
- if tmux_version() >= (3, 7):
2153
- key = _ROOT_STATUS_ACTION_KEYS[action]
2169
+ key = _ROOT_STATUS_ACTION_KEYS.get(action)
2170
+ if tmux_version() >= (3, 7) and key is not None:
2154
2171
  index = key.removeprefix("MouseDown1Control")
2155
2172
  return f"#[range=control|{index}]{content}#[norange]"
2156
2173
  return f"#[range=user|{action}]{content}#[norange]"
@@ -3079,7 +3096,7 @@ def set_root_status_click_forwarding(
3079
3096
  marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
3080
3097
  condition = (
3081
3098
  "#{&&:"
3082
- "#{m/r:^(%[0-9]+|railmux-(mode|layout|copy))$,"
3099
+ "#{m/r:^(%[0-9]+|railmux-(mode|layout|copy|page-[r12]))$,"
3083
3100
  "#{mouse_status_range}},"
3084
3101
  "#{!=:#{@railmux_controller_pane},},"
3085
3102
  f"#{{==:{marker},{marker}}}}}"
@@ -3097,6 +3114,18 @@ def set_root_status_click_forwarding(
3097
3114
  f'\'#{{{RAILMUX_CONTROLLER_OPTION}}}\' F7 ;; '
3098
3115
  f'{STATUS_ACTION_COPY}) {_tmux_shell_executable()} send-keys -t '
3099
3116
  f'\'#{{{RAILMUX_CONTROLLER_OPTION}}}\' F6 ;; '
3117
+ f'{STATUS_ACTION_PAGE_SIDEBAR}) '
3118
+ f'{_tmux_shell_executable()} send-keys -l -t '
3119
+ f'\'#{{{RAILMUX_CONTROLLER_OPTION}}}\' -- '
3120
+ f'{shlex.quote(RAILMUX_COMPACT_PAGE_KEYS[STATUS_ACTION_PAGE_SIDEBAR][1])} ;; '
3121
+ f'{STATUS_ACTION_PAGE_PRIMARY}) '
3122
+ f'{_tmux_shell_executable()} send-keys -l -t '
3123
+ f'\'#{{{RAILMUX_CONTROLLER_OPTION}}}\' -- '
3124
+ f'{shlex.quote(RAILMUX_COMPACT_PAGE_KEYS[STATUS_ACTION_PAGE_PRIMARY][1])} ;; '
3125
+ f'{STATUS_ACTION_PAGE_SECONDARY}) '
3126
+ f'{_tmux_shell_executable()} send-keys -l -t '
3127
+ f'\'#{{{RAILMUX_CONTROLLER_OPTION}}}\' -- '
3128
+ f'{shlex.quote(RAILMUX_COMPACT_PAGE_KEYS[STATUS_ACTION_PAGE_SECONDARY][1])} ;; '
3100
3129
  'esac"'
3101
3130
  )
3102
3131
  subprocess.check_call(