railmux 0.4.0.dev33__tar.gz → 0.4.0.dev35__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 (187) hide show
  1. {railmux-0.4.0.dev33/src/railmux.egg-info → railmux-0.4.0.dev35}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev35/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/display_transport.py +308 -0
  4. railmux-0.4.0.dev35/src/railmux/preview_pager.py +53 -0
  5. railmux-0.4.0.dev35/src/railmux/project_favorites.py +79 -0
  6. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/tmux_ctl.py +257 -46
  7. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/app.py +194 -31
  8. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/projects_pane.py +44 -9
  9. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35/src/railmux.egg-info}/PKG-INFO +1 -1
  10. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux.egg-info/SOURCES.txt +4 -0
  11. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_codex_delete.py +7 -11
  12. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_display_transport.py +163 -0
  13. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_focus_highlight.py +223 -3
  14. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_fullscreen_binding.py +19 -0
  15. railmux-0.4.0.dev35/tests/test_preview_pager.py +42 -0
  16. railmux-0.4.0.dev35/tests/test_project_favorites.py +52 -0
  17. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_projects_pane.py +65 -0
  18. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_sidebar.py +17 -0
  19. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_ctl.py +24 -7
  20. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_integration.py +49 -0
  21. railmux-0.4.0.dev33/src/railmux/__init__.py +0 -1
  22. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/LICENSE +0 -0
  23. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/MANIFEST.in +0 -0
  24. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/README.md +0 -0
  25. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/pyproject.toml +0 -0
  26. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/setup.cfg +0 -0
  27. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/__main__.py +0 -0
  28. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/atomic_file.py +0 -0
  29. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/background_index.py +0 -0
  30. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/cli.py +0 -0
  31. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/codex_index.py +0 -0
  32. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/config.py +0 -0
  33. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/config_cli.py +0 -0
  34. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/diagnostics.py +0 -0
  35. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/discovery.py +0 -0
  36. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/entrypoint.py +0 -0
  37. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fast_display_client.py +0 -0
  38. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fast_display_history.py +0 -0
  39. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fast_display_input.py +0 -0
  40. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fast_display_protocol.py +0 -0
  41. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fast_display_server.py +0 -0
  42. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/favorites.py +0 -0
  43. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/fuzzy.py +0 -0
  44. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/help_workspace.py +0 -0
  45. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/launcher.py +0 -0
  46. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/legacy_sessions.py +0 -0
  47. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/local_clipboard.py +0 -0
  48. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/local_open.py +0 -0
  49. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/models.py +0 -0
  50. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/modes.py +0 -0
  51. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/mouse_manager.py +0 -0
  52. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/orphan_marker.py +0 -0
  53. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/pane_surface.py +0 -0
  54. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/path_codec.py +0 -0
  55. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/provider_paths.py +0 -0
  56. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/remote_config.py +0 -0
  57. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/renames.py +0 -0
  58. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/restart_state.py +0 -0
  59. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/runtime_config.py +0 -0
  60. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/scroll_agent.py +0 -0
  61. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/scroll_manager.py +0 -0
  62. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/selection_isolation.py +0 -0
  63. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/self_update.py +0 -0
  64. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/session_cache.py +0 -0
  65. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/session_index.py +0 -0
  66. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/session_lease.py +0 -0
  67. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/setting_contracts.py +0 -0
  68. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/settings.py +0 -0
  69. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ssh_args.py +0 -0
  70. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ssh_compat.py +0 -0
  71. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ssh_display_diagnostics.py +0 -0
  72. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ssh_doctor.py +0 -0
  73. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/system_deps.py +0 -0
  74. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/terminal_status.py +0 -0
  75. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/tmux_binding_manager.py +0 -0
  76. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/tmux_health.py +0 -0
  77. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/tmux_server.py +0 -0
  78. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/tool_panes.py +0 -0
  79. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/transcript.py +0 -0
  80. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/__init__.py +0 -0
  81. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/_widgets.py +0 -0
  82. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/keymap.py +0 -0
  83. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/modals.py +0 -0
  84. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/running_pane.py +0 -0
  85. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/sessions_pane.py +0 -0
  86. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/sidebar.py +0 -0
  87. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/statusbar.py +0 -0
  88. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/ui/workspace.py +0 -0
  89. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_attach_relay.py +0 -0
  90. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_bootstrap.py +0 -0
  91. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_install_log.py +0 -0
  92. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_msys2.py +0 -0
  93. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_pacman.py +0 -0
  94. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_paths.py +0 -0
  95. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_tmux_lifecycle.py +0 -0
  96. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux/windows_ui_transition.py +0 -0
  97. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux.egg-info/dependency_links.txt +0 -0
  98. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux.egg-info/entry_points.txt +0 -0
  99. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux.egg-info/requires.txt +0 -0
  100. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/src/railmux.egg-info/top_level.txt +0 -0
  101. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_agent_workspace.py +0 -0
  102. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_atomic_file.py +0 -0
  103. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_background_index.py +0 -0
  104. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_cli.py +0 -0
  105. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_codex_index.py +0 -0
  106. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_codex_lineage.py +0 -0
  107. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_codex_transcript.py +0 -0
  108. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_config.py +0 -0
  109. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_config_cli.py +0 -0
  110. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_denested_transport_benchmark.py +0 -0
  111. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_diagnostics.py +0 -0
  112. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_discovery.py +0 -0
  113. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_fast_full_window.py +0 -0
  114. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_fuzzy.py +0 -0
  115. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_help_agent.py +0 -0
  116. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_help_workspace.py +0 -0
  117. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_history_cleanup.py +0 -0
  118. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_keymap.py +0 -0
  119. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_launcher.py +0 -0
  120. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_layout_profile.py +0 -0
  121. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_legacy_sessions.py +0 -0
  122. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_local_clipboard.py +0 -0
  123. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_local_open.py +0 -0
  124. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_modals.py +0 -0
  125. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_mode_selection.py +0 -0
  126. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_modes.py +0 -0
  127. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_mouse_manager.py +0 -0
  128. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_new_project.py +0 -0
  129. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_options.py +0 -0
  130. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_orphan_recovery.py +0 -0
  131. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_pane_surface.py +0 -0
  132. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_path_codec.py +0 -0
  133. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_provider_paths.py +0 -0
  134. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_release_notes.py +0 -0
  135. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_release_workflow.py +0 -0
  136. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_remote_config.py +0 -0
  137. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_renames.py +0 -0
  138. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_restart_state.py +0 -0
  139. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_running_pane.py +0 -0
  140. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_running_sort.py +0 -0
  141. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_runtime_config.py +0 -0
  142. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_scroll_agent.py +0 -0
  143. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_scroll_manager.py +0 -0
  144. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_selection_isolation.py +0 -0
  145. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_self_update.py +0 -0
  146. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_session_cache.py +0 -0
  147. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_session_index.py +0 -0
  148. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_session_lease.py +0 -0
  149. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_sessions_pane.py +0 -0
  150. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_setting_contracts.py +0 -0
  151. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_settings.py +0 -0
  152. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_soft_quit.py +0 -0
  153. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_ssh_args.py +0 -0
  154. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_ssh_compat.py +0 -0
  155. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_ssh_display_benchmark.py +0 -0
  156. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_ssh_display_diagnostics.py +0 -0
  157. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_ssh_doctor.py +0 -0
  158. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_status.py +0 -0
  159. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_statusbar.py +0 -0
  160. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_system_deps.py +0 -0
  161. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_terminal_size.py +0 -0
  162. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_terminal_status.py +0 -0
  163. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_termux_local_touch.py +0 -0
  164. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_binding_manager.py +0 -0
  165. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_health.py +0 -0
  166. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_server.py +0 -0
  167. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tmux_status.py +0 -0
  168. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_tool_panes.py +0 -0
  169. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_transcript.py +0 -0
  170. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_utf8_state.py +0 -0
  171. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_web_demo_recorder.py +0 -0
  172. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_wheel_smoke.py +0 -0
  173. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_widgets.py +0 -0
  174. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_attach_relay.py +0 -0
  175. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_bootstrap.py +0 -0
  176. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_install_log.py +0 -0
  177. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_msys2.py +0 -0
  178. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_pacman.py +0 -0
  179. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_paths.py +0 -0
  180. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_tmux_lifecycle.py +0 -0
  181. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_ui_transition.py +0 -0
  182. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_windows_wrapper_contract.py +0 -0
  183. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tests/test_yolo_prompt.py +0 -0
  184. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tools/denested_transport_benchmark.py +0 -0
  185. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tools/release_notes.py +0 -0
  186. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/tools/wheel_smoke.py +0 -0
  187. {railmux-0.4.0.dev33 → railmux-0.4.0.dev35}/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.0.dev33
3
+ Version: 0.4.0.dev35
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.dev35"
@@ -48,6 +48,8 @@ class AttachOutcome:
48
48
  kind: DisplayTransportKind
49
49
  reason: str | None = None
50
50
  fell_back: bool = False
51
+ display_stable: bool = False
52
+ target_unchanged: bool = False
51
53
 
52
54
 
53
55
  @dataclass(frozen=True)
@@ -178,6 +180,90 @@ def _verified_displayed(state: SwapState) -> bool:
178
180
  )
179
181
 
180
182
 
183
+ def _snapshot_verified_home(
184
+ snapshot: tmux_ctl.TmuxStateSnapshot,
185
+ state: SwapState,
186
+ ) -> bool:
187
+ real = snapshot.pane(state.agent_pane_id)
188
+ placeholder = snapshot.pane(state.placeholder_pane_id)
189
+ topology = snapshot.topology(state.agent_tmux_name)
190
+ return bool(
191
+ real is not None
192
+ and placeholder is not None
193
+ and topology is not None
194
+ and real.pane_pid == state.agent_pane_pid
195
+ and real.window_id == state.home_window_id
196
+ and placeholder.window_id == state.display_window_id
197
+ and state.home_window_id in topology.window_ids
198
+ )
199
+
200
+
201
+ def _snapshot_verified_displayed(
202
+ snapshot: tmux_ctl.TmuxStateSnapshot,
203
+ state: SwapState,
204
+ ) -> bool:
205
+ real = snapshot.pane(state.agent_pane_id)
206
+ placeholder = snapshot.pane(state.placeholder_pane_id)
207
+ topology = snapshot.topology(state.agent_tmux_name)
208
+ return bool(
209
+ real is not None
210
+ and placeholder is not None
211
+ and topology is not None
212
+ and real.pane_pid == state.agent_pane_pid
213
+ and real.window_id == state.display_window_id
214
+ and placeholder.window_id == state.home_window_id
215
+ and topology.attached_clients == 0
216
+ and topology.single_live_pane is not None
217
+ and topology.single_live_pane.pane_id == state.placeholder_pane_id
218
+ )
219
+
220
+
221
+ def _snapshot_marker_gate(
222
+ snapshot: tmux_ctl.TmuxStateSnapshot,
223
+ agent_tmux_name: str,
224
+ known_transactions: frozenset[str],
225
+ ) -> str | None:
226
+ for _window, marker, raw in snapshot.window_options:
227
+ if marker not in _MARKERS.values() or not raw:
228
+ continue
229
+ decoded = _decode_record(raw)
230
+ if decoded is None:
231
+ return "inconsistent swap recovery metadata exists"
232
+ state = decoded[1]
233
+ if (
234
+ state.agent_tmux_name == agent_tmux_name
235
+ and state.transaction_id not in known_transactions
236
+ ):
237
+ return "agent pane is owned by another swap transaction"
238
+ return None
239
+
240
+
241
+ def _marker_commands(
242
+ state: SwapState,
243
+ slot_key: str,
244
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
245
+ marker = _marker_for(slot_key)
246
+ raw = _encode_record(state, slot_key)
247
+ return (
248
+ ("set-window-option", "-t", state.home_window_id, marker, raw),
249
+ ("set-window-option", "-t", state.display_window_id, marker, raw),
250
+ )
251
+
252
+
253
+ def _snapshot_has_marker_pair(
254
+ snapshot: tmux_ctl.TmuxStateSnapshot,
255
+ state: SwapState,
256
+ slot_key: str,
257
+ ) -> bool:
258
+ marker = _marker_for(slot_key)
259
+ for window in (state.home_window_id, state.display_window_id):
260
+ raw = snapshot.window_option(window, marker)
261
+ decoded = _decode_record(raw) if raw else None
262
+ if decoded is None or decoded != (slot_key, state):
263
+ return False
264
+ return True
265
+
266
+
181
267
  class NestedDisplayTransport:
182
268
  """The compatibility transport: a nested tmux attach client."""
183
269
 
@@ -542,6 +628,224 @@ class AgentDisplayTransport:
542
628
  return "agent pane is owned by another swap transaction"
543
629
  return None
544
630
 
631
+ def _fast_switch_swap(
632
+ self,
633
+ slot: AgentSlot,
634
+ agent_tmux_name: str,
635
+ ) -> AttachOutcome | None:
636
+ """Switch an exact displayed swap in a bounded tmux transaction.
637
+
638
+ ``None`` means the optimized gate declined before making an
639
+ irreversible change and the established path should continue. Every
640
+ mutation remains journaled and is followed by a coherent identity
641
+ snapshot; a failed or ambiguous mutation returns an explicit failure
642
+ with recovery metadata retained.
643
+ """
644
+ old = slot.swap_state
645
+ if (
646
+ old is None
647
+ or slot.transport_kind is not DisplayTransportKind.SWAP
648
+ or slot.display_parked
649
+ or tmux_ctl.tmux_version() < (3, 1)
650
+ ):
651
+ return None
652
+ marker_names = tuple(_MARKERS.values())
653
+ before = tmux_ctl.tmux_state_snapshot(marker_names)
654
+ if before is None or not _snapshot_verified_displayed(before, old):
655
+ return None
656
+ if not _snapshot_has_marker_pair(before, old, slot.key):
657
+ return None
658
+
659
+ # Clicking the already displayed row is an identity revalidation, not
660
+ # a reason to rewrite markers, resize windows, or move panes.
661
+ if old.agent_tmux_name == agent_tmux_name:
662
+ return AttachOutcome(
663
+ True,
664
+ DisplayTransportKind.SWAP,
665
+ display_stable=True,
666
+ target_unchanged=True,
667
+ )
668
+
669
+ known = frozenset(
670
+ state.transaction_id
671
+ for candidate in self.workspace.slots
672
+ if (state := candidate.swap_state) is not None
673
+ )
674
+ if _snapshot_marker_gate(
675
+ before, agent_tmux_name, known) is not None:
676
+ return None
677
+ if any(
678
+ candidate is not slot
679
+ and candidate.agent_tmux_name == agent_tmux_name
680
+ for candidate in self.workspace.slots
681
+ ):
682
+ return None
683
+ target_topology = before.topology(agent_tmux_name)
684
+ target_real = (
685
+ target_topology.single_live_pane
686
+ if target_topology is not None
687
+ and target_topology.attached_clients == 0
688
+ else None
689
+ )
690
+ if target_real is None or any(
691
+ candidate is not slot
692
+ and candidate.swap_state is not None
693
+ and candidate.swap_state.agent_pane_id == target_real.pane_id
694
+ for candidate in self.workspace.slots
695
+ ):
696
+ return None
697
+
698
+ old_real = before.pane(old.agent_pane_id)
699
+ keeper = before.topology(old.keeper_session)
700
+ if (
701
+ old_real is None
702
+ or keeper is None
703
+ or keeper.session_id != old.keeper_session_id
704
+ or old.display_window_id not in keeper.window_ids
705
+ ):
706
+ return None
707
+
708
+ returning = self._with_phase(old, "returning")
709
+ return_commands = (
710
+ *_marker_commands(returning, slot.key),
711
+ (
712
+ "resize-window", "-t", old.agent_tmux_name,
713
+ "-x", str(old_real.width), "-y", str(old_real.height),
714
+ ),
715
+ (
716
+ "swap-pane", "-d", "-s", old.agent_pane_id,
717
+ "-t", old.placeholder_pane_id,
718
+ ),
719
+ )
720
+ # The coherent postcondition is authoritative even if the client loses
721
+ # its final acknowledgement after tmux completed the atomic pane move.
722
+ tmux_ctl.run_command_queue(return_commands)
723
+ after_home = tmux_ctl.tmux_state_snapshot(marker_names)
724
+ if after_home is None or not _snapshot_verified_home(after_home, old):
725
+ if (
726
+ after_home is not None
727
+ and _snapshot_verified_displayed(after_home, old)
728
+ ):
729
+ _write_marker_pair(old, slot.key)
730
+ return None
731
+ return AttachOutcome(
732
+ False,
733
+ DisplayTransportKind.SWAP,
734
+ "could not verify the previous agent at home; "
735
+ "recovery metadata was retained",
736
+ )
737
+ target_topology = after_home.topology(agent_tmux_name)
738
+ target_real = (
739
+ target_topology.single_live_pane
740
+ if target_topology is not None
741
+ and target_topology.attached_clients == 0
742
+ else None
743
+ )
744
+ placeholder = after_home.pane(old.placeholder_pane_id)
745
+ keeper = after_home.topology(old.keeper_session)
746
+ if (
747
+ target_real is None
748
+ or placeholder is None
749
+ or placeholder.window_id != old.display_window_id
750
+ or keeper is None
751
+ or keeper.session_id != old.keeper_session_id
752
+ or old.display_window_id not in keeper.window_ids
753
+ or _snapshot_marker_gate(
754
+ after_home, agent_tmux_name, known,
755
+ ) is not None
756
+ ):
757
+ # Old is already proven safe at home. The ordinary path can clear
758
+ # its matching marker and reevaluate the changed target topology.
759
+ return None
760
+
761
+ prepared = SwapState(
762
+ transaction_id=uuid.uuid4().hex,
763
+ agent_tmux_name=agent_tmux_name,
764
+ agent_pane_id=target_real.pane_id,
765
+ agent_pane_pid=target_real.pane_pid,
766
+ home_window_id=target_real.window_id,
767
+ placeholder_pane_id=old.placeholder_pane_id,
768
+ display_window_id=old.display_window_id,
769
+ keeper_session=old.keeper_session,
770
+ keeper_session_id=old.keeper_session_id,
771
+ outer_session_name=old.outer_session_name,
772
+ outer_session_id=old.outer_session_id,
773
+ owner_pane_id=old.owner_pane_id,
774
+ phase="prepared",
775
+ )
776
+ marker = _marker_for(slot.key)
777
+ switch_commands = (
778
+ ("set-window-option", "-t", old.home_window_id, "-u", marker),
779
+ ("set-window-option", "-t", old.display_window_id, "-u", marker),
780
+ (
781
+ "set-option", "-p", "-t", old.placeholder_pane_id,
782
+ "-u", tmux_server.HISTORY_SOURCE_OPTION,
783
+ ),
784
+ (
785
+ "set-option", "-p", "-t", old.placeholder_pane_id,
786
+ "-u", tmux_server.TRANSCRIPT_SOURCE_OPTION,
787
+ ),
788
+ *_marker_commands(prepared, slot.key),
789
+ (
790
+ "swap-pane", "-d", "-s", prepared.agent_pane_id,
791
+ "-t", prepared.placeholder_pane_id,
792
+ ),
793
+ )
794
+ tmux_ctl.run_guarded_window_transaction(
795
+ (
796
+ (old.home_window_id, marker, old.transaction_id),
797
+ (old.display_window_id, marker, old.transaction_id),
798
+ ),
799
+ switch_commands,
800
+ )
801
+ after_switch = tmux_ctl.tmux_state_snapshot(marker_names)
802
+ if (
803
+ after_switch is None
804
+ or not _snapshot_verified_displayed(after_switch, prepared)
805
+ ):
806
+ if (
807
+ after_switch is not None
808
+ and _snapshot_verified_home(after_switch, old)
809
+ and _snapshot_verified_home(after_switch, prepared)
810
+ ):
811
+ _clear_markers(prepared, slot.key)
812
+ return None
813
+ return AttachOutcome(
814
+ False,
815
+ DisplayTransportKind.SWAP,
816
+ "optimized pane switch could not be verified; "
817
+ "recovery metadata was retained",
818
+ )
819
+ displayed = self._with_phase(prepared, "displayed")
820
+ committed = tmux_ctl.run_guarded_window_transaction(
821
+ (
822
+ (prepared.home_window_id, marker, prepared.transaction_id),
823
+ (
824
+ prepared.display_window_id,
825
+ marker,
826
+ prepared.transaction_id,
827
+ ),
828
+ ),
829
+ _marker_commands(displayed, slot.key),
830
+ )
831
+ slot.swap_state = displayed if committed else prepared
832
+ slot.pane_id = prepared.agent_pane_id
833
+ slot.transport_kind = DisplayTransportKind.SWAP
834
+ slot.agent_tmux_name = agent_tmux_name
835
+ slot.display_parked = False
836
+ if not committed:
837
+ return AttachOutcome(
838
+ False,
839
+ DisplayTransportKind.SWAP,
840
+ "pane switch committed but its recovery marker did not; "
841
+ "the verified pane was retained for recovery",
842
+ )
843
+ return AttachOutcome(
844
+ True,
845
+ DisplayTransportKind.SWAP,
846
+ display_stable=True,
847
+ )
848
+
545
849
  def attach(
546
850
  self,
547
851
  slot: AgentSlot,
@@ -571,6 +875,10 @@ class AgentDisplayTransport:
571
875
  True, DisplayTransportKind.NESTED, reason, fell_back=True)
572
876
  return outcome
573
877
 
878
+ optimized = self._fast_switch_swap(slot, agent_tmux_name)
879
+ if optimized is not None:
880
+ return optimized
881
+
574
882
  if (slot.swap_state is not None
575
883
  and slot.swap_state.agent_tmux_name == agent_tmux_name
576
884
  and slot.display_parked):
@@ -0,0 +1,53 @@
1
+ """Render a transcript completely before presenting its final page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from contextlib import redirect_stdout
10
+
11
+ from railmux import transcript
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ arguments = list(sys.argv[1:] if argv is None else argv)
16
+ mouse = False
17
+ if "--mouse" in arguments:
18
+ arguments.remove("--mouse")
19
+ mouse = True
20
+
21
+ # TemporaryFile is unnamed (or unlinked immediately) on the managed POSIX
22
+ # runtimes. The formatted derivative is seekable for less +G, never
23
+ # becomes a reusable transcript cache, and disappears when this process
24
+ # exits. Provider JSONL files remain read-only.
25
+ with tempfile.TemporaryFile(
26
+ mode="w+", encoding="utf-8", newline="",
27
+ ) as rendered:
28
+ try:
29
+ with redirect_stdout(rendered):
30
+ transcript.main(["transcript", *arguments])
31
+ except SystemExit as exc:
32
+ return int(exc.code) if isinstance(exc.code, int) else 1
33
+ rendered.flush()
34
+ rendered.seek(0)
35
+ less_argv = ["less", "-R", "+G"]
36
+ if mouse:
37
+ less_argv.extend(("--mouse", "--wheel-lines=3"))
38
+ environ = os.environ.copy()
39
+ environ.update({
40
+ "LESSSECURE": "1",
41
+ "LESSHISTFILE": "-",
42
+ "LESSOPEN": "",
43
+ "LESSCLOSE": "",
44
+ })
45
+ try:
46
+ return subprocess.call(less_argv, stdin=rendered, env=environ)
47
+ except OSError as exc:
48
+ print(f"Could not start read-only pager: {exc}", file=sys.stderr)
49
+ return 1
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ """Persistent favorite project tracking.
2
+
3
+ Project favorites are deliberately separate from session favorites. A
4
+ project is identified by the absolute path Railmux already discovered, while
5
+ session favorites are provider UUIDs and retain their released JSON schema.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from railmux.atomic_file import atomic_write_text
13
+
14
+
15
+ _MAX_FAVORITES = 10_000
16
+ _MAX_PATH_LENGTH = 4096
17
+
18
+
19
+ def _project_favorites_path() -> Path:
20
+ return Path.home() / ".config" / "railmux" / "project-favorites.json"
21
+
22
+
23
+ def _path_key(path: Path) -> str:
24
+ """Return the stable absolute spelling used by project discovery."""
25
+ return str(path if path.is_absolute() else path.absolute())
26
+
27
+
28
+ class ProjectFavorites:
29
+ """In-memory set of favorite absolute project paths, backed by JSON."""
30
+
31
+ def __init__(self) -> None:
32
+ self._paths: set[str] = set()
33
+ self._path = _project_favorites_path()
34
+ self._load()
35
+
36
+ def _load(self) -> None:
37
+ if not self._path.is_file():
38
+ return
39
+ try:
40
+ data = json.loads(self._path.read_text(encoding="utf-8"))
41
+ if (
42
+ not isinstance(data, list)
43
+ or len(data) > _MAX_FAVORITES
44
+ or any(
45
+ not isinstance(value, str)
46
+ or not value
47
+ or "\x00" in value
48
+ or len(value) > _MAX_PATH_LENGTH
49
+ or not Path(value).is_absolute()
50
+ for value in data
51
+ )
52
+ ):
53
+ return
54
+ self._paths = set(data)
55
+ except (json.JSONDecodeError, OSError, UnicodeError):
56
+ self._paths = set()
57
+
58
+ def _save(self) -> None:
59
+ try:
60
+ atomic_write_text(
61
+ self._path,
62
+ json.dumps(sorted(self._paths), ensure_ascii=False, indent=2),
63
+ )
64
+ except OSError:
65
+ pass
66
+
67
+ def is_favorite(self, path: Path) -> bool:
68
+ return _path_key(path) in self._paths
69
+
70
+ def set(self, path: Path, favorite: bool) -> None:
71
+ key = _path_key(path)
72
+ updated = self._paths | {key} if favorite else self._paths - {key}
73
+ if updated == self._paths:
74
+ return
75
+ self._paths = updated
76
+ self._save()
77
+
78
+ def get_paths(self) -> set[Path]:
79
+ return {Path(value) for value in self._paths}