railmux 0.4.0.dev19__tar.gz → 0.4.0.dev20__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 (179) hide show
  1. {railmux-0.4.0.dev19/src/railmux.egg-info → railmux-0.4.0.dev20}/PKG-INFO +6 -6
  2. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/README.md +5 -5
  3. railmux-0.4.0.dev20/src/railmux/__init__.py +1 -0
  4. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/cli.py +15 -0
  5. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fast_display_history.py +128 -73
  6. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fast_display_server.py +33 -16
  7. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/tmux_ctl.py +7 -41
  8. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/tmux_server.py +21 -0
  9. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/app.py +244 -42
  10. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/modals.py +81 -0
  11. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20/src/railmux.egg-info}/PKG-INFO +6 -6
  12. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_agent_workspace.py +1 -0
  13. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_cli.py +26 -0
  14. railmux-0.4.0.dev20/tests/test_codex_lineage.py +561 -0
  15. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_fast_full_window.py +252 -65
  16. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_focus_highlight.py +6 -2
  17. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_modals.py +68 -0
  18. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_soft_quit.py +101 -1
  19. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_ctl.py +0 -24
  20. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_integration.py +12 -49
  21. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_server.py +40 -4
  22. railmux-0.4.0.dev19/src/railmux/__init__.py +0 -1
  23. railmux-0.4.0.dev19/tests/test_codex_lineage.py +0 -253
  24. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/LICENSE +0 -0
  25. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/MANIFEST.in +0 -0
  26. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/pyproject.toml +0 -0
  27. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/setup.cfg +0 -0
  28. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/__main__.py +0 -0
  29. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/atomic_file.py +0 -0
  30. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/background_index.py +0 -0
  31. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/codex_index.py +0 -0
  32. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/config.py +0 -0
  33. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/config_cli.py +0 -0
  34. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/diagnostics.py +0 -0
  35. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/discovery.py +0 -0
  36. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/display_transport.py +0 -0
  37. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/entrypoint.py +0 -0
  38. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fast_display_client.py +0 -0
  39. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fast_display_input.py +0 -0
  40. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fast_display_protocol.py +0 -0
  41. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/favorites.py +0 -0
  42. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/fuzzy.py +0 -0
  43. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/help_workspace.py +0 -0
  44. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/launcher.py +0 -0
  45. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/legacy_sessions.py +0 -0
  46. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/local_clipboard.py +0 -0
  47. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/local_open.py +0 -0
  48. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/models.py +0 -0
  49. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/modes.py +0 -0
  50. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/mouse_manager.py +0 -0
  51. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/orphan_marker.py +0 -0
  52. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/pane_surface.py +0 -0
  53. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/path_codec.py +0 -0
  54. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/provider_paths.py +0 -0
  55. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/remote_config.py +0 -0
  56. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/renames.py +0 -0
  57. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/restart_state.py +0 -0
  58. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/runtime_config.py +0 -0
  59. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/scroll_agent.py +0 -0
  60. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/scroll_manager.py +0 -0
  61. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/selection_isolation.py +0 -0
  62. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/self_update.py +0 -0
  63. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/session_cache.py +0 -0
  64. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/session_index.py +0 -0
  65. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/session_lease.py +0 -0
  66. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/setting_contracts.py +0 -0
  67. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/settings.py +0 -0
  68. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ssh_args.py +0 -0
  69. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ssh_compat.py +0 -0
  70. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ssh_display_diagnostics.py +0 -0
  71. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ssh_doctor.py +0 -0
  72. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/system_deps.py +0 -0
  73. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/terminal_status.py +0 -0
  74. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/tmux_binding_manager.py +0 -0
  75. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/tmux_health.py +0 -0
  76. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/tool_panes.py +0 -0
  77. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/transcript.py +0 -0
  78. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/__init__.py +0 -0
  79. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/_widgets.py +0 -0
  80. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/keymap.py +0 -0
  81. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/projects_pane.py +0 -0
  82. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/running_pane.py +0 -0
  83. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/sessions_pane.py +0 -0
  84. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/sidebar.py +0 -0
  85. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/statusbar.py +0 -0
  86. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/ui/workspace.py +0 -0
  87. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_attach_relay.py +0 -0
  88. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_bootstrap.py +0 -0
  89. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_install_log.py +0 -0
  90. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_msys2.py +0 -0
  91. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_pacman.py +0 -0
  92. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux/windows_tmux_lifecycle.py +0 -0
  93. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux.egg-info/SOURCES.txt +0 -0
  94. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux.egg-info/dependency_links.txt +0 -0
  95. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux.egg-info/entry_points.txt +0 -0
  96. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux.egg-info/requires.txt +0 -0
  97. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/src/railmux.egg-info/top_level.txt +0 -0
  98. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_atomic_file.py +0 -0
  99. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_background_index.py +0 -0
  100. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_codex_delete.py +0 -0
  101. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_codex_index.py +0 -0
  102. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_codex_transcript.py +0 -0
  103. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_config.py +0 -0
  104. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_config_cli.py +0 -0
  105. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_denested_transport_benchmark.py +0 -0
  106. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_diagnostics.py +0 -0
  107. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_discovery.py +0 -0
  108. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_display_transport.py +0 -0
  109. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_fullscreen_binding.py +0 -0
  110. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_fuzzy.py +0 -0
  111. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_help_agent.py +0 -0
  112. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_help_workspace.py +0 -0
  113. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_history_cleanup.py +0 -0
  114. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_keymap.py +0 -0
  115. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_launcher.py +0 -0
  116. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_layout_profile.py +0 -0
  117. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_legacy_sessions.py +0 -0
  118. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_local_clipboard.py +0 -0
  119. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_local_open.py +0 -0
  120. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_mode_selection.py +0 -0
  121. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_modes.py +0 -0
  122. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_mouse_manager.py +0 -0
  123. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_new_project.py +0 -0
  124. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_options.py +0 -0
  125. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_orphan_recovery.py +0 -0
  126. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_pane_surface.py +0 -0
  127. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_path_codec.py +0 -0
  128. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_projects_pane.py +0 -0
  129. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_provider_paths.py +0 -0
  130. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_release_notes.py +0 -0
  131. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_release_workflow.py +0 -0
  132. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_remote_config.py +0 -0
  133. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_renames.py +0 -0
  134. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_restart_state.py +0 -0
  135. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_running_pane.py +0 -0
  136. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_running_sort.py +0 -0
  137. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_runtime_config.py +0 -0
  138. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_scroll_agent.py +0 -0
  139. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_scroll_manager.py +0 -0
  140. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_selection_isolation.py +0 -0
  141. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_self_update.py +0 -0
  142. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_session_cache.py +0 -0
  143. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_session_index.py +0 -0
  144. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_session_lease.py +0 -0
  145. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_sessions_pane.py +0 -0
  146. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_setting_contracts.py +0 -0
  147. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_settings.py +0 -0
  148. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_sidebar.py +0 -0
  149. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_ssh_args.py +0 -0
  150. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_ssh_compat.py +0 -0
  151. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_ssh_display_benchmark.py +0 -0
  152. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_ssh_display_diagnostics.py +0 -0
  153. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_ssh_doctor.py +0 -0
  154. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_status.py +0 -0
  155. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_statusbar.py +0 -0
  156. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_system_deps.py +0 -0
  157. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_terminal_size.py +0 -0
  158. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_terminal_status.py +0 -0
  159. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_termux_local_touch.py +0 -0
  160. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_binding_manager.py +0 -0
  161. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_health.py +0 -0
  162. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tmux_status.py +0 -0
  163. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_tool_panes.py +0 -0
  164. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_transcript.py +0 -0
  165. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_utf8_state.py +0 -0
  166. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_web_demo_recorder.py +0 -0
  167. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_wheel_smoke.py +0 -0
  168. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_widgets.py +0 -0
  169. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_attach_relay.py +0 -0
  170. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_bootstrap.py +0 -0
  171. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_install_log.py +0 -0
  172. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_msys2.py +0 -0
  173. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_pacman.py +0 -0
  174. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_tmux_lifecycle.py +0 -0
  175. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_windows_wrapper_contract.py +0 -0
  176. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tests/test_yolo_prompt.py +0 -0
  177. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tools/denested_transport_benchmark.py +0 -0
  178. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/tools/release_notes.py +0 -0
  179. {railmux-0.4.0.dev19 → railmux-0.4.0.dev20}/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.dev19
3
+ Version: 0.4.0.dev20
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
@@ -384,10 +384,9 @@ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
384
384
  terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
385
385
  per-pane history and normally preserves the live terminal's captured styling.
386
386
  When Railmux confirms that a running Codex session advanced after rewind or
387
- steering, it resets only the current terminal view before Codex redraws and
388
- invalidates that pane's SSH history generation. Retained tmux scrollback is not
389
- erased; the SSH history for that branched generation uses the canonical
390
- rollout so its abandoned suffix stays hidden.
387
+ steering, it leaves the live terminal untouched and advances only that pane's
388
+ SSH history generation. The SSH history for that branched generation uses the
389
+ canonical rollout so its abandoned suffix stays hidden.
391
390
 
392
391
  Preview opens at the latest activity in `less`; large sessions are limited to
393
392
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -446,7 +445,8 @@ Each opened agent runs in a detached tmux session, so switching sessions does
446
445
  not interrupt it. To leave agents running when you quit Railmux, press `s` for
447
446
  soft quit in the confirmation popup; restarting the same Railmux instance then
448
447
  restores the usable workspace when those sessions are still available. A normal
449
- quit confirmation ends all running sessions instead.
448
+ quit ends all running sessions instead and therefore requires a second
449
+ confirmation; `y` and Enter keep their existing confirm meaning in both steps.
450
450
 
451
451
  All terminals attached to one managed Railmux window view the same UI process.
452
452
  Soft Quit therefore closes that shared UI for every attached view, although it
@@ -341,10 +341,9 @@ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
341
341
  terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
342
342
  per-pane history and normally preserves the live terminal's captured styling.
343
343
  When Railmux confirms that a running Codex session advanced after rewind or
344
- steering, it resets only the current terminal view before Codex redraws and
345
- invalidates that pane's SSH history generation. Retained tmux scrollback is not
346
- erased; the SSH history for that branched generation uses the canonical
347
- rollout so its abandoned suffix stays hidden.
344
+ steering, it leaves the live terminal untouched and advances only that pane's
345
+ SSH history generation. The SSH history for that branched generation uses the
346
+ canonical rollout so its abandoned suffix stays hidden.
348
347
 
349
348
  Preview opens at the latest activity in `less`; large sessions are limited to
350
349
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -403,7 +402,8 @@ Each opened agent runs in a detached tmux session, so switching sessions does
403
402
  not interrupt it. To leave agents running when you quit Railmux, press `s` for
404
403
  soft quit in the confirmation popup; restarting the same Railmux instance then
405
404
  restores the usable workspace when those sessions are still available. A normal
406
- quit confirmation ends all running sessions instead.
405
+ quit ends all running sessions instead and therefore requires a second
406
+ confirmation; `y` and Enter keep their existing confirm meaning in both steps.
407
407
 
408
408
  All terminals attached to one managed Railmux window view the same UI process.
409
409
  Soft Quit therefore closes that shared UI for every attached view, although it
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev20"
@@ -45,6 +45,20 @@ _LOCAL_WATCHDOG_INTERVAL = 5.0
45
45
  _LOCAL_WATCHDOG_FAILURES = 3
46
46
 
47
47
 
48
+ def _interactive_terminal_size() -> tuple[int, int] | None:
49
+ """Return an exact entry TTY size without manufacturing a fallback."""
50
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
51
+ return None
52
+ for stream in (sys.stdout, sys.stdin):
53
+ try:
54
+ size = os.get_terminal_size(stream.fileno())
55
+ except (AttributeError, OSError):
56
+ continue
57
+ if 0 < size.columns <= 65535 and 0 < size.lines <= 65535:
58
+ return size.columns, size.lines
59
+ return None
60
+
61
+
48
62
  def _restore_terminal(attributes: list | None) -> None:
49
63
  if attributes is None:
50
64
  return
@@ -627,6 +641,7 @@ def main(argv: list[str] | None = None) -> int:
627
641
  launch_prefix,
628
642
  raw_args,
629
643
  env=client_env,
644
+ initial_size=_interactive_terminal_size(),
630
645
  )
631
646
  )
632
647
  cmd = tmux_server.launcher_argv(launch_prefix, raw_args)
@@ -109,6 +109,10 @@ class _PendingHistory:
109
109
  pane_id: str
110
110
  target_lines: int
111
111
  requested_at: float
112
+ # A first wheel-up waits for one cumulative snapshot before freezing the
113
+ # viewport. This avoids exposing a short hot suffix whose later deep page
114
+ # may not share a stable anchor while the provider is actively repainting.
115
+ initial_offset: int | None = None
112
116
 
113
117
 
114
118
  class LocalHistoryView:
@@ -266,8 +270,6 @@ class LocalHistoryView:
266
270
  self,
267
271
  previous: HistorySnapshot,
268
272
  incoming: HistorySnapshot,
269
- *,
270
- prefer_deeper: bool = False,
271
273
  ) -> HistorySnapshot:
272
274
  """Retain already-fetched history when a later hot capture shrinks."""
273
275
  if not self._same_geometry(
@@ -276,28 +278,13 @@ class LocalHistoryView:
276
278
  return incoming
277
279
  delta = self._timeline_delta(previous.lines, incoming.lines)
278
280
  if delta is None:
279
- if prefer_deeper and len(incoming.lines) > len(previous.lines):
280
- # A deep response reaches here only after accept() proved that
281
- # the user's immutable frozen viewport still exists in it.
282
- # Rewind/full-screen diff output can make every individual
283
- # line in that viewport non-unique, leaving the generic
284
- # timeline voter without two anchors. The larger cumulative
285
- # capture is nevertheless stronger than the 300-line hot
286
- # cache; keeping only its live tail would silently discard all
287
- # intermediate output above the current screen.
288
- lines = incoming.lines
289
- else:
290
- # Never splice two captures whose timelines cannot be aligned.
291
- # Retaining old history and replacing only the live viewport
292
- # looked conservative, but it silently omitted every row
293
- # produced between those two pieces. That false seam appeared
294
- # as discontinuous SSH history until returning to the bottom
295
- # forced a fresh deep capture. Keep the newest internally
296
- # contiguous hot capture instead; the first wheel-up requests
297
- # a cumulative deep page and can recover older rows safely.
298
- # An already-frozen viewport owns its immutable snapshot and
299
- # therefore remains undisturbed by this cache replacement.
300
- lines = incoming.lines
281
+ # Never splice two captures whose timelines cannot be aligned.
282
+ # Retaining old history and replacing only the live viewport
283
+ # looked conservative, but it silently omitted every row produced
284
+ # between those two pieces. Keep the newest internally contiguous
285
+ # capture instead. An already-frozen viewport owns its immutable
286
+ # snapshot and therefore remains undisturbed by cache replacement.
287
+ lines = incoming.lines
301
288
  else:
302
289
  start = min(0, delta)
303
290
  incoming_end = delta + len(incoming.lines)
@@ -325,8 +312,6 @@ class LocalHistoryView:
325
312
  def _remember_content(
326
313
  self,
327
314
  snapshot: HistorySnapshot,
328
- *,
329
- prefer_deeper: bool = False,
330
315
  ) -> HistorySnapshot:
331
316
  assert snapshot.pane_id is not None
332
317
  previous = self.content_cache.get(snapshot.pane_id)
@@ -344,8 +329,7 @@ class LocalHistoryView:
344
329
  stored = self._merge_content(previous, snapshot) if anchored else snapshot
345
330
  self._unverified_after_reconnect.discard(snapshot.pane_id)
346
331
  else:
347
- stored = self._merge_content(
348
- previous, snapshot, prefer_deeper=prefer_deeper)
332
+ stored = self._merge_content(previous, snapshot)
349
333
  # Reinsert an existing pane to keep insertion order as recency order.
350
334
  self.content_cache.pop(snapshot.pane_id, None)
351
335
  self.content_cache[snapshot.pane_id] = stored
@@ -353,6 +337,15 @@ class LocalHistoryView:
353
337
  del self.content_cache[next(iter(self.content_cache))]
354
338
  return stored
355
339
 
340
+ def _replace_content(self, snapshot: HistorySnapshot) -> HistorySnapshot:
341
+ """Install one verified contiguous capture without timeline merging."""
342
+ assert snapshot.pane_id is not None
343
+ self.content_cache.pop(snapshot.pane_id, None)
344
+ self.content_cache[snapshot.pane_id] = snapshot
345
+ while len(self.content_cache) > _HISTORY_CONTENT_PANES:
346
+ del self.content_cache[next(iter(self.content_cache))]
347
+ return snapshot
348
+
356
349
  def invalidate_routes(self) -> bool:
357
350
  """Drop pointer authority without discarding bounded pane content."""
358
351
  was_active = self.cancel()
@@ -470,29 +463,32 @@ class LocalHistoryView:
470
463
  cached = self.content_cache.get(route.pane_id, route)
471
464
  if not self._same_geometry(cached, route):
472
465
  cached = route
466
+ loaded_limit = min(len(cached.lines), self.history_limit)
467
+ target_lines = min(self.history_limit, _HISTORY_INITIAL_LINES)
468
+ if (
469
+ cached.more_available
470
+ and loaded_limit < min(target_lines, _HISTORY_PREFETCH_LINES)
471
+ ):
472
+ self.cancel_pane(route.pane_id)
473
+ request_id = self._allocate_request_id()
474
+ self._deep_pending[request_id] = _PendingHistory(
475
+ self.route_epoch,
476
+ route.pane_id,
477
+ target_lines,
478
+ time.monotonic() if now is None else now,
479
+ max(1, initial_offset),
480
+ )
481
+ self._deep_requests += 1
482
+ # A byte-budgeted or older peer may return less than the normal
483
+ # coherent routing capture. Keep the live pane intact until one
484
+ # cumulative response arrives rather than expose a short suffix.
485
+ return HistoryAction(
486
+ protocol_frame=encode_history_request(
487
+ request_id, event.x, event.y, target_lines
488
+ )
489
+ )
473
490
  maximum = max(0, len(cached.lines) - cached.height)
474
491
  if maximum == 0:
475
- if cached.more_available:
476
- self.cancel_pane(route.pane_id)
477
- target_lines = min(self.history_limit, _HISTORY_INITIAL_LINES)
478
- self.viewports[route.pane_id] = _HistoryViewport(
479
- cached,
480
- 0,
481
- min(len(cached.lines), self.history_limit),
482
- )
483
- request_id = self._allocate_request_id()
484
- self._deep_pending[request_id] = _PendingHistory(
485
- self.route_epoch,
486
- route.pane_id,
487
- target_lines,
488
- time.monotonic() if now is None else now,
489
- )
490
- self._deep_requests += 1
491
- return HistoryAction(
492
- protocol_frame=encode_history_request(
493
- request_id, event.x, event.y, target_lines
494
- )
495
- )
496
492
  if cached.transcript_backed:
497
493
  # Claude may not have written its first transcript record yet.
498
494
  # This is a normal transient state, not a Railmux status event:
@@ -503,7 +499,6 @@ class LocalHistoryView:
503
499
  forwarded_input=event.raw if cached.mouse_forwardable else b""
504
500
  )
505
501
  self.cancel_pane(route.pane_id)
506
- loaded_limit = min(len(cached.lines), self.history_limit)
507
502
  viewport = _HistoryViewport(
508
503
  cached,
509
504
  min(maximum, max(1, initial_offset)),
@@ -513,23 +508,15 @@ class LocalHistoryView:
513
508
  ),
514
509
  )
515
510
  self.viewports[route.pane_id] = viewport
516
- target_lines = min(self.history_limit, _HISTORY_INITIAL_LINES)
517
- if loaded_limit >= target_lines:
518
- return HistoryAction(
519
- protocol_frame=self._extend_history(viewport, now=now),
520
- render_history=True,
521
- )
522
- request_id = self._allocate_request_id()
523
- self._deep_pending[request_id] = _PendingHistory(
524
- self.route_epoch,
525
- route.pane_id,
526
- target_lines,
527
- time.monotonic() if now is None else now,
528
- )
529
- self._deep_requests += 1
511
+ # A complete 300-row routing capture is already one coherent history
512
+ # source. Paint it synchronously, then fetch a cumulative page only as
513
+ # the viewport approaches its top. This removes an SSH round trip from
514
+ # the first wheel tick without reintroducing a hot/deep splice.
530
515
  return HistoryAction(
531
- protocol_frame=encode_history_request(
532
- request_id, event.x, event.y, target_lines
516
+ protocol_frame=self._extend_history(
517
+ viewport,
518
+ now=now,
519
+ request_position=(event.x, event.y),
533
520
  ),
534
521
  render_history=True,
535
522
  )
@@ -539,6 +526,7 @@ class LocalHistoryView:
539
526
  viewport: _HistoryViewport,
540
527
  *,
541
528
  now: float | None = None,
529
+ request_position: tuple[int, int] | None = None,
542
530
  ) -> bytes:
543
531
  """Request the next cumulative page when a viewport nears its top."""
544
532
  snapshot = viewport.snapshot
@@ -572,7 +560,9 @@ class LocalHistoryView:
572
560
  return b""
573
561
  target_lines = min(
574
562
  self.history_limit,
575
- max(_HISTORY_INITIAL_LINES, viewport.loaded_limit) + _HISTORY_PAGE_LINES,
563
+ _HISTORY_INITIAL_LINES
564
+ if viewport.loaded_limit < _HISTORY_INITIAL_LINES
565
+ else viewport.loaded_limit + _HISTORY_PAGE_LINES,
576
566
  )
577
567
  request_id = self._allocate_request_id()
578
568
  self._deep_pending[request_id] = _PendingHistory(
@@ -582,10 +572,14 @@ class LocalHistoryView:
582
572
  requested_at,
583
573
  )
584
574
  self._deep_requests += 1
585
- return encode_history_request(
586
- request_id,
575
+ request_x, request_y = request_position or (
587
576
  snapshot.x + 1,
588
577
  snapshot.y + 1,
578
+ )
579
+ return encode_history_request(
580
+ request_id,
581
+ request_x,
582
+ request_y,
589
583
  target_lines,
590
584
  )
591
585
 
@@ -602,6 +596,45 @@ class LocalHistoryView:
602
596
  if direction > 0 and route.history_choice_required:
603
597
  return HistoryAction(claude_history_prompt=event.raw)
604
598
  viewport = self.viewports.get(route.pane_id)
599
+ initial_request = next(
600
+ (
601
+ (request_id, pending)
602
+ for request_id, pending in self._deep_pending.items()
603
+ if pending.pane_id == route.pane_id
604
+ and pending.initial_offset is not None
605
+ ),
606
+ None,
607
+ )
608
+ if initial_request is not None:
609
+ request_id, pending = initial_request
610
+ requested_at = time.monotonic() if now is None else now
611
+ if requested_at - pending.requested_at >= _HISTORY_DEEP_TIMEOUT:
612
+ self._deep_pending.pop(request_id, None)
613
+ self._timeouts += 1
614
+ if direction < 0:
615
+ return HistoryAction()
616
+ assert pending.initial_offset is not None
617
+ distance = min(
618
+ self.history_limit,
619
+ pending.initial_offset + max(1, distance),
620
+ )
621
+ initial_request = None
622
+ if viewport is None and initial_request is not None:
623
+ if direction < 0:
624
+ # The live pane never left the screen, so reversing direction
625
+ # simply cancels the pending entry. A late response is ignored.
626
+ self.cancel_pane(route.pane_id)
627
+ return HistoryAction()
628
+ request_id, pending = initial_request
629
+ assert pending.initial_offset is not None
630
+ self._deep_pending[request_id] = replace(
631
+ pending,
632
+ initial_offset=min(
633
+ self.history_limit,
634
+ pending.initial_offset + max(1, distance),
635
+ ),
636
+ )
637
+ return HistoryAction()
605
638
  if viewport is not None:
606
639
  maximum = max(0, len(viewport.snapshot.lines) - viewport.snapshot.height)
607
640
  viewport.offset = max(
@@ -828,8 +861,26 @@ class LocalHistoryView:
828
861
  return HistoryAction()
829
862
  viewport = self.viewports.get(pending.pane_id)
830
863
  if viewport is None:
831
- self._remember_content(snapshot)
832
- return HistoryAction()
864
+ if pending.initial_offset is None:
865
+ self._remember_content(snapshot)
866
+ return HistoryAction()
867
+ maximum = max(0, len(snapshot.lines) - snapshot.height)
868
+ if maximum == 0:
869
+ self._remember_content(snapshot)
870
+ return HistoryAction()
871
+ stored = self._replace_content(snapshot)
872
+ maximum = max(0, len(stored.lines) - stored.height)
873
+ viewport = _HistoryViewport(
874
+ stored,
875
+ min(maximum, pending.initial_offset),
876
+ min(self.history_limit, max(pending.target_lines, len(stored.lines))),
877
+ exhausted=not snapshot.more_available,
878
+ )
879
+ self.viewports[pending.pane_id] = viewport
880
+ return HistoryAction(
881
+ protocol_frame=self._extend_history(viewport),
882
+ render_history=True,
883
+ )
833
884
  maximum = max(0, len(snapshot.lines) - snapshot.height)
834
885
  if maximum == 0:
835
886
  self._remember_content(snapshot)
@@ -843,7 +894,11 @@ class LocalHistoryView:
843
894
  # snapshot instead of jumping to newer or unrelated text.
844
895
  self._anchor_rejects += 1
845
896
  return HistoryAction()
846
- stored = self._remember_content(snapshot, prefer_deeper=True)
897
+ # The response is one cumulative capture and its exact visible anchor
898
+ # was just proved above. Replace the cached timeline as a whole instead
899
+ # of merging it with the older hot generation; this keeps every row's
900
+ # text and background styling from one server render.
901
+ stored = self._replace_content(snapshot)
847
902
  stored_offset = self._aligned_offset(stored, anchor)
848
903
  if stored_offset is None:
849
904
  # The raw response was valid, so this can only be an ambiguous
@@ -892,7 +947,7 @@ class LocalHistoryView:
892
947
  return matched_offset
893
948
 
894
949
  # Agent status rows (for example a Codex spinner) can change between
895
- # the 300-line hot snapshot and the first deep response. Requiring the
950
+ # the hot snapshot and a deep response. Requiring the
896
951
  # entire visible viewport to remain byte-identical then strands the
897
952
  # user at the hot-cache boundary. Align on a majority of stable,
898
953
  # non-blank lines instead, but only when each evidence line occurs
@@ -1171,12 +1171,35 @@ def apply_path_open_request(
1171
1171
  )
1172
1172
 
1173
1173
 
1174
- def _render_history_line(pyte: object, line: bytes, width: int) -> bytes:
1175
- """Parse one physical tmux line and emit only allowlisted SGR styling."""
1174
+ def _render_history_lines(
1175
+ pyte: object,
1176
+ lines: Sequence[bytes],
1177
+ width: int,
1178
+ ) -> tuple[bytes, ...]:
1179
+ """Render physical tmux rows while retaining cross-row terminal style.
1180
+
1181
+ ``tmux capture-pane -e`` is a terminal stream, not a collection of
1182
+ independently styled strings. In particular, tmux omits an SGR prefix
1183
+ when a row inherits the previous row's foreground, background, or text
1184
+ attributes. Keep one decoder alive for the ordered capture, but clear its
1185
+ one-row cell buffer between rows. :func:`render_rows` then makes every
1186
+ result independently paintable for the local history overlay.
1187
+ """
1176
1188
  screen = pyte.Screen(width, 1)
1177
1189
  stream = pyte.ByteStream(screen)
1178
- stream.feed(line)
1179
- return render_rows(screen)[0]
1190
+ rendered: list[bytes] = []
1191
+ for line in lines:
1192
+ screen.buffer[0].clear()
1193
+ screen.cursor.x = 0
1194
+ screen.cursor.y = 0
1195
+ stream.feed(line)
1196
+ rendered.append(render_rows(screen)[0])
1197
+ return tuple(rendered)
1198
+
1199
+
1200
+ def _render_history_line(pyte: object, line: bytes, width: int) -> bytes:
1201
+ """Render one self-contained physical row with allowlisted SGR styling."""
1202
+ return _render_history_lines(pyte, (line,), width)[0]
1180
1203
 
1181
1204
 
1182
1205
  def _read_transcript_tail(fd: int) -> tuple[str, bool]:
@@ -1352,7 +1375,8 @@ def _capture_pane_history(
1352
1375
  # Retain the newest suffix when the styled representation reaches its byte
1353
1376
  # budget. Iterate backwards so an oversized response never sacrifices the
1354
1377
  # pane's current viewport merely to retain older scrollback.
1355
- current_raw = raw_lines[-pane.height :]
1378
+ rendered_raw = _render_history_lines(pyte, raw_lines, pane.width)
1379
+ current_raw = rendered_raw[-pane.height :]
1356
1380
  transcript_entry = None
1357
1381
  # ``None`` is retained only for old/test geometry that predates the
1358
1382
  # provider field; every validated live marker carries an explicit value.
@@ -1383,21 +1407,14 @@ def _capture_pane_history(
1383
1407
  transcript_lines = transcript_entry.rows
1384
1408
  history_count = max(0, max_lines - pane.height)
1385
1409
  source_lines = (
1386
- tuple((line, True) for line in transcript_lines[-history_count:])
1387
- if history_count
1388
- else ()
1389
- ) + tuple((line, False) for line in current_raw)
1410
+ transcript_lines[-history_count:] if history_count else ()
1411
+ ) + current_raw
1390
1412
  else:
1391
- source_lines = tuple((line, False) for line in raw_lines)
1413
+ source_lines = rendered_raw
1392
1414
  newest_first: list[bytes] = []
1393
1415
  packed_size = 2 # history line-count prefix
1394
1416
  budget_truncated = False
1395
- for raw_line, rendered_already in reversed(source_lines[-max_lines:]):
1396
- rendered = (
1397
- raw_line
1398
- if rendered_already
1399
- else _render_history_line(pyte, raw_line, pane.width)
1400
- )
1417
+ for rendered in reversed(source_lines[-max_lines:]):
1401
1418
  line_size = 4 + len(rendered)
1402
1419
  if packed_size + line_size > _HISTORY_SNAPSHOT_RAW_BUDGET:
1403
1420
  budget_truncated = True
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  import re
11
- import signal
12
11
  import shlex
13
12
  import shutil
14
13
  import subprocess
@@ -1849,7 +1848,13 @@ RAILMUX_HISTORY_GENERATION_OPTION = "@railmux_history_generation_v1"
1849
1848
  # A confirmed branch transition prefixes the opaque SSH history cache epoch.
1850
1849
  # Plain generations use native pane history; only this explicit marker permits
1851
1850
  # the canonical transcript fallback that removes an abandoned rewind suffix.
1852
- RAILMUX_CANONICAL_HISTORY_PREFIX = "canonical:"
1851
+ # Only the evidence-gated v2 marker authorizes transcript-backed Codex
1852
+ # scrolling. The released v1 marker treated every live direct child rollout
1853
+ # as a rewind, but Codex can also create that shape while bootstrapping an
1854
+ # ordinary resume. Keeping the prefix versioned makes existing over-broad
1855
+ # markers fail back to raw pane history on upgrade.
1856
+ RAILMUX_CANONICAL_HISTORY_PREFIX = "canonical-v2:"
1857
+ RAILMUX_LEGACY_CANONICAL_HISTORY_PREFIX = "canonical:"
1853
1858
  # tmux names function keys only through F12. Private restore routes use
1854
1859
  # canonical xterm sequences in literal mode, so they cannot be confused with
1855
1860
  # user input forwarded to an agent pane.
@@ -3432,45 +3437,6 @@ def exact_pane_alive(identity: PaneIdentity) -> bool:
3432
3437
  )
3433
3438
 
3434
3439
 
3435
- def reset_pane_view(identity: PaneIdentity) -> bool:
3436
- """Reset visible terminal state without deleting retained scrollback.
3437
-
3438
- Revalidate the immutable session, pane process, and window before issuing
3439
- tmux's terminal reset. Then send the pane's foreground process group an
3440
- ordinary same-size ``SIGWINCH`` so its TUI repaints and reasserts terminal
3441
- modes without a geometry change or injected keyboard input. The reset can
3442
- push the old viewport into tmux history, but never destroys the retained
3443
- prefix; managed history surfaces use the provider's canonical transcript.
3444
- """
3445
- current = pane_identity(identity.pane_id)
3446
- if (current is None or current.dead
3447
- or current.pane_pid != identity.pane_pid
3448
- or current.session_id != identity.session_id
3449
- or current.window_id != identity.window_id):
3450
- return False
3451
- try:
3452
- subprocess.check_call(
3453
- ["tmux", "send-keys", "-R", "-t", identity.pane_id],
3454
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
3455
- )
3456
- except (OSError, subprocess.CalledProcessError):
3457
- return False
3458
-
3459
- current = pane_identity(identity.pane_id)
3460
- if (current is None or current.dead
3461
- or current.pane_pid != identity.pane_pid
3462
- or current.session_id != identity.session_id
3463
- or current.window_id != identity.window_id):
3464
- return True
3465
- try:
3466
- os.killpg(os.getpgid(current.pane_pid), signal.SIGWINCH)
3467
- except (OSError, ValueError):
3468
- # The presentation cache is already clean. A provider that exited or
3469
- # cannot be signalled will repaint on its next ordinary terminal write.
3470
- pass
3471
- return True
3472
-
3473
-
3474
3440
  def kill_session_identity(identity: PaneIdentity) -> bool:
3475
3441
  """Kill an exact immutable tmux session, never a reused session name."""
3476
3442
  current = pane_identity(identity.pane_id)
@@ -680,12 +680,32 @@ def _managed_windows_session_environment_args(
680
680
  return result
681
681
 
682
682
 
683
+ def _initial_session_size_args(
684
+ initial_size: tuple[int, int] | None,
685
+ ) -> list[str]:
686
+ """Return bounded tmux dimensions for a newly created detached window."""
687
+ if not isinstance(initial_size, tuple) or len(initial_size) != 2:
688
+ return []
689
+ width, height = initial_size
690
+ if (
691
+ isinstance(width, bool)
692
+ or isinstance(height, bool)
693
+ or not isinstance(width, int)
694
+ or not isinstance(height, int)
695
+ or not 0 < width <= 65535
696
+ or not 0 < height <= 65535
697
+ ):
698
+ return []
699
+ return ["-x", str(width), "-y", str(height)]
700
+
701
+
683
702
  def ensure_detached_launcher_session(
684
703
  target: TmuxServerTarget,
685
704
  launch_prefix: Sequence[str],
686
705
  forwarded_args: Sequence[str],
687
706
  *,
688
707
  env: Mapping[str, str] | None = None,
708
+ initial_size: tuple[int, int] | None = None,
689
709
  timeout: float = 5.0,
690
710
  ) -> str | None:
691
711
  """Create the outer UI detached on one already-proven server.
@@ -713,6 +733,7 @@ def ensure_detached_launcher_session(
713
733
  "-d",
714
734
  "-s",
715
735
  "railmux",
736
+ *_initial_session_size_args(initial_size),
716
737
  *_managed_windows_session_environment_args(env),
717
738
  *launch_prefix,
718
739
  "--inside-tmux",