railmux 0.4.0.dev19__tar.gz → 0.4.0.dev21__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.dev21}/PKG-INFO +8 -6
  2. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/README.md +7 -5
  3. railmux-0.4.0.dev21/src/railmux/__init__.py +1 -0
  4. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/cli.py +15 -0
  5. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fast_display_client.py +114 -25
  6. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fast_display_history.py +134 -82
  7. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fast_display_server.py +33 -16
  8. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/tmux_ctl.py +7 -41
  9. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/tmux_server.py +21 -0
  10. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/app.py +244 -42
  11. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/modals.py +128 -25
  12. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21/src/railmux.egg-info}/PKG-INFO +8 -6
  13. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_agent_workspace.py +1 -0
  14. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_cli.py +26 -0
  15. railmux-0.4.0.dev21/tests/test_codex_lineage.py +561 -0
  16. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_fast_full_window.py +339 -79
  17. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_focus_highlight.py +6 -2
  18. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_modals.py +88 -8
  19. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_soft_quit.py +101 -1
  20. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_ctl.py +0 -24
  21. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_integration.py +12 -49
  22. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_server.py +40 -4
  23. railmux-0.4.0.dev19/src/railmux/__init__.py +0 -1
  24. railmux-0.4.0.dev19/tests/test_codex_lineage.py +0 -253
  25. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/LICENSE +0 -0
  26. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/MANIFEST.in +0 -0
  27. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/pyproject.toml +0 -0
  28. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/setup.cfg +0 -0
  29. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/__main__.py +0 -0
  30. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/atomic_file.py +0 -0
  31. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/background_index.py +0 -0
  32. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/codex_index.py +0 -0
  33. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/config.py +0 -0
  34. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/config_cli.py +0 -0
  35. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/diagnostics.py +0 -0
  36. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/discovery.py +0 -0
  37. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/display_transport.py +0 -0
  38. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/entrypoint.py +0 -0
  39. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fast_display_input.py +0 -0
  40. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fast_display_protocol.py +0 -0
  41. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/favorites.py +0 -0
  42. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/fuzzy.py +0 -0
  43. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/help_workspace.py +0 -0
  44. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/launcher.py +0 -0
  45. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/legacy_sessions.py +0 -0
  46. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/local_clipboard.py +0 -0
  47. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/local_open.py +0 -0
  48. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/models.py +0 -0
  49. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/modes.py +0 -0
  50. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/mouse_manager.py +0 -0
  51. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/orphan_marker.py +0 -0
  52. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/pane_surface.py +0 -0
  53. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/path_codec.py +0 -0
  54. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/provider_paths.py +0 -0
  55. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/remote_config.py +0 -0
  56. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/renames.py +0 -0
  57. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/restart_state.py +0 -0
  58. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/runtime_config.py +0 -0
  59. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/scroll_agent.py +0 -0
  60. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/scroll_manager.py +0 -0
  61. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/selection_isolation.py +0 -0
  62. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/self_update.py +0 -0
  63. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/session_cache.py +0 -0
  64. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/session_index.py +0 -0
  65. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/session_lease.py +0 -0
  66. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/setting_contracts.py +0 -0
  67. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/settings.py +0 -0
  68. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ssh_args.py +0 -0
  69. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ssh_compat.py +0 -0
  70. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ssh_display_diagnostics.py +0 -0
  71. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ssh_doctor.py +0 -0
  72. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/system_deps.py +0 -0
  73. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/terminal_status.py +0 -0
  74. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/tmux_binding_manager.py +0 -0
  75. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/tmux_health.py +0 -0
  76. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/tool_panes.py +0 -0
  77. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/transcript.py +0 -0
  78. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/__init__.py +0 -0
  79. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/_widgets.py +0 -0
  80. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/keymap.py +0 -0
  81. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/projects_pane.py +0 -0
  82. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/running_pane.py +0 -0
  83. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/sessions_pane.py +0 -0
  84. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/sidebar.py +0 -0
  85. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/statusbar.py +0 -0
  86. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/ui/workspace.py +0 -0
  87. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_attach_relay.py +0 -0
  88. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_bootstrap.py +0 -0
  89. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_install_log.py +0 -0
  90. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_msys2.py +0 -0
  91. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_pacman.py +0 -0
  92. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux/windows_tmux_lifecycle.py +0 -0
  93. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux.egg-info/SOURCES.txt +0 -0
  94. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux.egg-info/dependency_links.txt +0 -0
  95. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux.egg-info/entry_points.txt +0 -0
  96. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux.egg-info/requires.txt +0 -0
  97. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/src/railmux.egg-info/top_level.txt +0 -0
  98. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_atomic_file.py +0 -0
  99. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_background_index.py +0 -0
  100. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_codex_delete.py +0 -0
  101. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_codex_index.py +0 -0
  102. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_codex_transcript.py +0 -0
  103. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_config.py +0 -0
  104. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_config_cli.py +0 -0
  105. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_denested_transport_benchmark.py +0 -0
  106. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_diagnostics.py +0 -0
  107. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_discovery.py +0 -0
  108. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_display_transport.py +0 -0
  109. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_fullscreen_binding.py +0 -0
  110. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_fuzzy.py +0 -0
  111. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_help_agent.py +0 -0
  112. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_help_workspace.py +0 -0
  113. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_history_cleanup.py +0 -0
  114. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_keymap.py +0 -0
  115. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_launcher.py +0 -0
  116. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_layout_profile.py +0 -0
  117. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_legacy_sessions.py +0 -0
  118. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_local_clipboard.py +0 -0
  119. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_local_open.py +0 -0
  120. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_mode_selection.py +0 -0
  121. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_modes.py +0 -0
  122. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_mouse_manager.py +0 -0
  123. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_new_project.py +0 -0
  124. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_options.py +0 -0
  125. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_orphan_recovery.py +0 -0
  126. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_pane_surface.py +0 -0
  127. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_path_codec.py +0 -0
  128. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_projects_pane.py +0 -0
  129. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_provider_paths.py +0 -0
  130. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_release_notes.py +0 -0
  131. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_release_workflow.py +0 -0
  132. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_remote_config.py +0 -0
  133. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_renames.py +0 -0
  134. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_restart_state.py +0 -0
  135. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_running_pane.py +0 -0
  136. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_running_sort.py +0 -0
  137. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_runtime_config.py +0 -0
  138. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_scroll_agent.py +0 -0
  139. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_scroll_manager.py +0 -0
  140. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_selection_isolation.py +0 -0
  141. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_self_update.py +0 -0
  142. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_session_cache.py +0 -0
  143. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_session_index.py +0 -0
  144. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_session_lease.py +0 -0
  145. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_sessions_pane.py +0 -0
  146. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_setting_contracts.py +0 -0
  147. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_settings.py +0 -0
  148. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_sidebar.py +0 -0
  149. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_ssh_args.py +0 -0
  150. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_ssh_compat.py +0 -0
  151. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_ssh_display_benchmark.py +0 -0
  152. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_ssh_display_diagnostics.py +0 -0
  153. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_ssh_doctor.py +0 -0
  154. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_status.py +0 -0
  155. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_statusbar.py +0 -0
  156. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_system_deps.py +0 -0
  157. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_terminal_size.py +0 -0
  158. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_terminal_status.py +0 -0
  159. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_termux_local_touch.py +0 -0
  160. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_binding_manager.py +0 -0
  161. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_health.py +0 -0
  162. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tmux_status.py +0 -0
  163. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_tool_panes.py +0 -0
  164. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_transcript.py +0 -0
  165. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_utf8_state.py +0 -0
  166. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_web_demo_recorder.py +0 -0
  167. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_wheel_smoke.py +0 -0
  168. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_widgets.py +0 -0
  169. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_attach_relay.py +0 -0
  170. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_bootstrap.py +0 -0
  171. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_install_log.py +0 -0
  172. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_msys2.py +0 -0
  173. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_pacman.py +0 -0
  174. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_tmux_lifecycle.py +0 -0
  175. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_windows_wrapper_contract.py +0 -0
  176. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tests/test_yolo_prompt.py +0 -0
  177. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tools/denested_transport_benchmark.py +0 -0
  178. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/tools/release_notes.py +0 -0
  179. {railmux-0.4.0.dev19 → railmux-0.4.0.dev21}/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.dev21
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,10 @@ 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. The first chooser has visible mouse buttons, but the final
450
+ destructive confirmation remains keyboard-only; `y` and Enter keep their
451
+ existing confirm meaning in both steps.
450
452
 
451
453
  All terminals attached to one managed Railmux window view the same UI process.
452
454
  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,10 @@ 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. The first chooser has visible mouse buttons, but the final
407
+ destructive confirmation remains keyboard-only; `y` and Enter keep their
408
+ existing confirm meaning in both steps.
407
409
 
408
410
  All terminals attached to one managed Railmux window view the same UI process.
409
411
  Soft Quit therefore closes that shared UI for every attached view, although it
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev21"
@@ -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)
@@ -39,7 +39,7 @@ from railmux.fast_display_history import (
39
39
  HistoryAction,
40
40
  LocalHistoryView,
41
41
  PeriodicPrefetchGate,
42
- claim_batched_wheel,
42
+ claim_batched_forwarded_wheel,
43
43
  input_may_change_routes,
44
44
  )
45
45
  from railmux.fast_display_input import (
@@ -1423,6 +1423,73 @@ class TerminalSurface:
1423
1423
  self.interaction_active = False
1424
1424
 
1425
1425
 
1426
+ @dataclass
1427
+ class _DeferredHistoryPaint:
1428
+ """Collapse one local-input batch into its final history viewport paint."""
1429
+
1430
+ render_history: bool = False
1431
+ restore_live: bool = False
1432
+
1433
+ def defer(self, action: HistoryAction) -> None:
1434
+ self.render_history = self.render_history or action.render_history
1435
+ self.restore_live = self.restore_live or action.restore_live
1436
+
1437
+ def flush(
1438
+ self,
1439
+ surface: TerminalSurface,
1440
+ screen: AppliedScreen | None,
1441
+ overlays: tuple[tuple[HistorySnapshot, tuple[bytes, ...]], ...],
1442
+ selection: tuple[SelectionSegment, ...],
1443
+ ) -> None:
1444
+ if screen is not None:
1445
+ if self.restore_live:
1446
+ # A viewport may have reached the live bottom and another may
1447
+ # still be frozen. Restore the authoritative frame and compose
1448
+ # only the final overlays in one terminal write.
1449
+ surface.paint(full_repaint(screen), overlays, selection)
1450
+ elif self.render_history:
1451
+ surface.paint_overlays(screen, overlays, selection)
1452
+ self.render_history = False
1453
+ self.restore_live = False
1454
+
1455
+
1456
+ class _LocalInputBatch:
1457
+ """Preserve local wheel distance while bounding forwarded wheel bursts."""
1458
+
1459
+ def __init__(self) -> None:
1460
+ self._forwarded_wheel_directions: set[int] = set()
1461
+ self._history_paint = _DeferredHistoryPaint()
1462
+
1463
+ def prepare(
1464
+ self,
1465
+ event: SgrMouseEvent,
1466
+ action: HistoryAction,
1467
+ ) -> tuple[HistoryAction | None, _DeferredHistoryPaint | None]:
1468
+ if event.wheel_direction == 0:
1469
+ return action, None
1470
+ if action.forwarded_input:
1471
+ if not claim_batched_forwarded_wheel(
1472
+ event,
1473
+ self._forwarded_wheel_directions,
1474
+ ):
1475
+ return None, None
1476
+ return action, None
1477
+ # Every locally owned history tick has already advanced the viewport.
1478
+ # Defer only its terminal paint so a Windows/RDP read containing
1479
+ # several SGR packets preserves the complete distance without issuing
1480
+ # one synchronous pane repaint per packet.
1481
+ return action, self._history_paint
1482
+
1483
+ def flush(
1484
+ self,
1485
+ surface: TerminalSurface,
1486
+ screen: AppliedScreen | None,
1487
+ overlays: tuple[tuple[HistorySnapshot, tuple[bytes, ...]], ...],
1488
+ selection: tuple[SelectionSegment, ...],
1489
+ ) -> None:
1490
+ self._history_paint.flush(surface, screen, overlays, selection)
1491
+
1492
+
1426
1493
  def _remote_server_args(
1427
1494
  *,
1428
1495
  session: str,
@@ -2605,22 +2672,28 @@ def run(args: argparse.Namespace) -> int:
2605
2672
  if frame is not None:
2606
2673
  send_protocol_frame(frame)
2607
2674
 
2608
- def apply_history_action(action: HistoryAction) -> None:
2675
+ def apply_history_action(
2676
+ action: HistoryAction,
2677
+ deferred_paint: _DeferredHistoryPaint | None = None,
2678
+ ) -> None:
2609
2679
  nonlocal route_refresh_needed
2610
2680
  nonlocal history_info_until, claude_history_prompt_input
2611
2681
  overlays = history.overlays()
2612
- if action.restore_live and latest_screen is not None:
2613
- surface.paint(
2614
- full_repaint(latest_screen),
2615
- overlays,
2616
- selection.segments(),
2617
- )
2618
- elif action.render_history and latest_screen is not None:
2619
- surface.paint_overlays(
2620
- latest_screen,
2621
- overlays,
2622
- selection.segments(),
2623
- )
2682
+ if deferred_paint is not None:
2683
+ deferred_paint.defer(action)
2684
+ else:
2685
+ if action.restore_live and latest_screen is not None:
2686
+ surface.paint(
2687
+ full_repaint(latest_screen),
2688
+ overlays,
2689
+ selection.segments(),
2690
+ )
2691
+ elif action.render_history and latest_screen is not None:
2692
+ surface.paint_overlays(
2693
+ latest_screen,
2694
+ overlays,
2695
+ selection.segments(),
2696
+ )
2624
2697
  if action.protocol_frame:
2625
2698
  send_protocol_frame(action.protocol_frame)
2626
2699
  if action.forwarded_input:
@@ -2637,7 +2710,7 @@ def run(args: argparse.Namespace) -> int:
2637
2710
 
2638
2711
  def handle_terminal_part(
2639
2712
  part: bytes | SgrMouseEvent,
2640
- handled_wheels: set[int],
2713
+ input_batch: _LocalInputBatch,
2641
2714
  ) -> None:
2642
2715
  nonlocal route_refresh_needed
2643
2716
  nonlocal claude_history_prompt_input, claude_history_pending_choice
@@ -2900,15 +2973,18 @@ def run(args: argparse.Namespace) -> int:
2900
2973
  selection_changed_rows(selection_before, selection_after),
2901
2974
  )
2902
2975
  for replay_event in selection_action.replay_events:
2903
- if not claim_batched_wheel(replay_event, handled_wheels):
2904
- continue
2905
2976
  replay_action = history.pointer_event(
2906
2977
  replay_event,
2907
2978
  focused_pane_id,
2908
2979
  status_row=status_row,
2909
2980
  now=time.monotonic(),
2910
2981
  )
2911
- apply_history_action(replay_action)
2982
+ replay_action, deferred_paint = input_batch.prepare(
2983
+ replay_event,
2984
+ replay_action,
2985
+ )
2986
+ if replay_action is not None:
2987
+ apply_history_action(replay_action, deferred_paint)
2912
2988
  if selection_action.copy_data is not None:
2913
2989
  surface.copy_to_clipboard(selection_action.copy_data)
2914
2990
  character_count = len(
@@ -2944,15 +3020,15 @@ def run(args: argparse.Namespace) -> int:
2944
3020
  begin_path_open(target)
2945
3021
  if selection_action.handled:
2946
3022
  return
2947
- if not claim_batched_wheel(part, handled_wheels):
2948
- return
2949
3023
  action = history.pointer_event(
2950
3024
  part,
2951
3025
  focused_pane_id,
2952
3026
  status_row=status_row,
2953
3027
  now=time.monotonic(),
2954
3028
  )
2955
- apply_history_action(action)
3029
+ action, deferred_paint = input_batch.prepare(part, action)
3030
+ if action is not None:
3031
+ apply_history_action(action, deferred_paint)
2956
3032
  return
2957
3033
  if not part:
2958
3034
  return
@@ -3327,19 +3403,32 @@ def run(args: argparse.Namespace) -> int:
3327
3403
  data, emergency_exit = split_local_escape(data)
3328
3404
  if emergency_exit:
3329
3405
  local_exit = True
3330
- handled_wheels: set[int] = set()
3406
+ input_batch = _LocalInputBatch()
3331
3407
  for part in terminal_input.feed(data):
3332
3408
  if isinstance(part, bytes):
3333
3409
  for key_part in split_page_key_input(part):
3334
- handle_terminal_part(key_part, handled_wheels)
3410
+ handle_terminal_part(key_part, input_batch)
3335
3411
  else:
3336
- handle_terminal_part(part, handled_wheels)
3412
+ handle_terminal_part(part, input_batch)
3413
+ input_batch.flush(
3414
+ surface,
3415
+ latest_screen,
3416
+ history.overlays(),
3417
+ selection.segments(),
3418
+ )
3337
3419
  if local_exit:
3338
3420
  break
3339
3421
  if not local_exit:
3340
3422
  for part in terminal_input.flush_pending():
3423
+ input_batch = _LocalInputBatch()
3341
3424
  for key_part in split_page_key_input(part):
3342
- handle_terminal_part(key_part, set())
3425
+ handle_terminal_part(key_part, input_batch)
3426
+ input_batch.flush(
3427
+ surface,
3428
+ latest_screen,
3429
+ history.overlays(),
3430
+ selection.segments(),
3431
+ )
3343
3432
  now = time.monotonic()
3344
3433
  keyboard_projected = touch_keyboard.keyboard_projected
3345
3434
  if touch_keyboard.expire(now):
@@ -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
@@ -966,18 +1021,15 @@ class LocalHistoryView:
966
1021
  return was_active
967
1022
 
968
1023
 
969
- def claim_batched_wheel(
1024
+ def claim_batched_forwarded_wheel(
970
1025
  event: SgrMouseEvent,
971
1026
  handled_directions: set[int],
972
1027
  ) -> bool:
973
- """Admit at most one wheel tick per direction from one terminal read.
974
-
975
- Trackpads commonly report several identical SGR wheel packets together.
976
- If painting is briefly busy with fresh agent output, those packets can
977
- accumulate in stdin and then move a frozen local-history viewport many
978
- lines at once. Bounding each read preserves the established one-line
979
- local scroll feel without using a timing heuristic; a later read remains
980
- immediately eligible.
1028
+ """Admit at most one forwarded wheel tick per direction in one read.
1029
+
1030
+ Remote sidebar/modal owners need one bounded event rather than a stale
1031
+ backlog. Locally owned agent history must not use this helper: it retains
1032
+ the complete reported distance and coalesces only its terminal paint.
981
1033
  """
982
1034
  direction = event.wheel_direction
983
1035
  if direction == 0: