railmux 0.3.6.dev3__tar.gz → 0.3.6.dev5__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 (162) hide show
  1. {railmux-0.3.6.dev3/src/railmux.egg-info → railmux-0.3.6.dev5}/PKG-INFO +4 -2
  2. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/README.md +3 -1
  3. railmux-0.3.6.dev5/src/railmux/__init__.py +1 -0
  4. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fast_display_client.py +114 -25
  5. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fast_display_history.py +6 -9
  6. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fast_display_server.py +33 -16
  7. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/modals.py +47 -25
  8. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5/src/railmux.egg-info}/PKG-INFO +4 -2
  9. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_fast_full_window.py +135 -32
  10. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_modals.py +23 -11
  11. railmux-0.3.6.dev3/src/railmux/__init__.py +0 -1
  12. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/LICENSE +0 -0
  13. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/MANIFEST.in +0 -0
  14. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/pyproject.toml +0 -0
  15. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/setup.cfg +0 -0
  16. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/__main__.py +0 -0
  17. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/atomic_file.py +0 -0
  18. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/background_index.py +0 -0
  19. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/cli.py +0 -0
  20. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/codex_index.py +0 -0
  21. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/config.py +0 -0
  22. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/config_cli.py +0 -0
  23. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/diagnostics.py +0 -0
  24. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/discovery.py +0 -0
  25. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/display_transport.py +0 -0
  26. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fast_display_input.py +0 -0
  27. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fast_display_protocol.py +0 -0
  28. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/favorites.py +0 -0
  29. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/fuzzy.py +0 -0
  30. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/help_workspace.py +0 -0
  31. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/launcher.py +0 -0
  32. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/legacy_sessions.py +0 -0
  33. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/local_clipboard.py +0 -0
  34. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/local_open.py +0 -0
  35. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/models.py +0 -0
  36. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/modes.py +0 -0
  37. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/mouse_manager.py +0 -0
  38. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/orphan_marker.py +0 -0
  39. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/pane_surface.py +0 -0
  40. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/path_codec.py +0 -0
  41. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/remote_config.py +0 -0
  42. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/renames.py +0 -0
  43. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/restart_state.py +0 -0
  44. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/runtime_config.py +0 -0
  45. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/scroll_agent.py +0 -0
  46. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/scroll_manager.py +0 -0
  47. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/selection_isolation.py +0 -0
  48. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/self_update.py +0 -0
  49. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/session_cache.py +0 -0
  50. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/session_index.py +0 -0
  51. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/session_lease.py +0 -0
  52. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/setting_contracts.py +0 -0
  53. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/settings.py +0 -0
  54. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ssh_args.py +0 -0
  55. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ssh_compat.py +0 -0
  56. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ssh_display_diagnostics.py +0 -0
  57. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ssh_doctor.py +0 -0
  58. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/system_deps.py +0 -0
  59. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/terminal_status.py +0 -0
  60. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/tmux_binding_manager.py +0 -0
  61. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/tmux_ctl.py +0 -0
  62. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/tmux_health.py +0 -0
  63. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/tmux_server.py +0 -0
  64. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/tool_panes.py +0 -0
  65. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/transcript.py +0 -0
  66. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/__init__.py +0 -0
  67. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/_widgets.py +0 -0
  68. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/app.py +0 -0
  69. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/keymap.py +0 -0
  70. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/projects_pane.py +0 -0
  71. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/running_pane.py +0 -0
  72. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/sessions_pane.py +0 -0
  73. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/sidebar.py +0 -0
  74. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/statusbar.py +0 -0
  75. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux/ui/workspace.py +0 -0
  76. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux.egg-info/SOURCES.txt +0 -0
  77. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux.egg-info/dependency_links.txt +0 -0
  78. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux.egg-info/entry_points.txt +0 -0
  79. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux.egg-info/requires.txt +0 -0
  80. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/src/railmux.egg-info/top_level.txt +0 -0
  81. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_agent_workspace.py +0 -0
  82. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_atomic_file.py +0 -0
  83. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_background_index.py +0 -0
  84. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_cli.py +0 -0
  85. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_codex_delete.py +0 -0
  86. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_codex_index.py +0 -0
  87. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_codex_lineage.py +0 -0
  88. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_codex_transcript.py +0 -0
  89. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_config.py +0 -0
  90. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_config_cli.py +0 -0
  91. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_denested_transport_benchmark.py +0 -0
  92. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_diagnostics.py +0 -0
  93. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_discovery.py +0 -0
  94. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_display_transport.py +0 -0
  95. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_focus_highlight.py +0 -0
  96. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_fullscreen_binding.py +0 -0
  97. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_fuzzy.py +0 -0
  98. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_help_agent.py +0 -0
  99. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_help_workspace.py +0 -0
  100. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_history_cleanup.py +0 -0
  101. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_keymap.py +0 -0
  102. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_launcher.py +0 -0
  103. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_layout_profile.py +0 -0
  104. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_legacy_sessions.py +0 -0
  105. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_local_clipboard.py +0 -0
  106. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_local_open.py +0 -0
  107. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_mode_selection.py +0 -0
  108. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_modes.py +0 -0
  109. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_mouse_manager.py +0 -0
  110. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_new_project.py +0 -0
  111. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_options.py +0 -0
  112. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_orphan_recovery.py +0 -0
  113. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_pane_surface.py +0 -0
  114. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_path_codec.py +0 -0
  115. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_projects_pane.py +0 -0
  116. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_release_notes.py +0 -0
  117. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_release_workflow.py +0 -0
  118. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_remote_config.py +0 -0
  119. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_renames.py +0 -0
  120. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_restart_state.py +0 -0
  121. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_running_pane.py +0 -0
  122. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_running_sort.py +0 -0
  123. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_runtime_config.py +0 -0
  124. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_scroll_agent.py +0 -0
  125. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_scroll_manager.py +0 -0
  126. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_selection_isolation.py +0 -0
  127. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_self_update.py +0 -0
  128. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_session_cache.py +0 -0
  129. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_session_index.py +0 -0
  130. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_session_lease.py +0 -0
  131. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_sessions_pane.py +0 -0
  132. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_setting_contracts.py +0 -0
  133. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_settings.py +0 -0
  134. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_sidebar.py +0 -0
  135. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_soft_quit.py +0 -0
  136. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_ssh_args.py +0 -0
  137. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_ssh_compat.py +0 -0
  138. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_ssh_display_benchmark.py +0 -0
  139. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_ssh_display_diagnostics.py +0 -0
  140. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_ssh_doctor.py +0 -0
  141. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_status.py +0 -0
  142. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_statusbar.py +0 -0
  143. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_system_deps.py +0 -0
  144. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_terminal_size.py +0 -0
  145. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_terminal_status.py +0 -0
  146. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_termux_local_touch.py +0 -0
  147. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_binding_manager.py +0 -0
  148. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_ctl.py +0 -0
  149. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_health.py +0 -0
  150. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_integration.py +0 -0
  151. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_server.py +0 -0
  152. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tmux_status.py +0 -0
  153. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_tool_panes.py +0 -0
  154. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_transcript.py +0 -0
  155. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_utf8_state.py +0 -0
  156. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_web_demo_recorder.py +0 -0
  157. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_wheel_smoke.py +0 -0
  158. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_widgets.py +0 -0
  159. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tests/test_yolo_prompt.py +0 -0
  160. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tools/denested_transport_benchmark.py +0 -0
  161. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tools/release_notes.py +0 -0
  162. {railmux-0.3.6.dev3 → railmux-0.3.6.dev5}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.6.dev3
3
+ Version: 0.3.6.dev5
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
@@ -445,7 +445,9 @@ not interrupt it. To leave agents running when you quit Railmux, press `s` for
445
445
  soft quit in the confirmation popup; restarting the same Railmux instance then
446
446
  restores the usable workspace when those sessions are still available. A normal
447
447
  quit ends all running sessions instead and therefore requires a second
448
- confirmation; `y` and Enter keep their existing confirm meaning in both steps.
448
+ confirmation. The first chooser has visible mouse buttons, but the final
449
+ destructive confirmation remains keyboard-only; `y` and Enter keep their
450
+ existing confirm meaning in both steps.
449
451
 
450
452
  All terminals attached to one managed Railmux window view the same UI process.
451
453
  Soft Quit therefore closes that shared UI for every attached view, although it
@@ -403,7 +403,9 @@ not interrupt it. To leave agents running when you quit Railmux, press `s` for
403
403
  soft quit in the confirmation popup; restarting the same Railmux instance then
404
404
  restores the usable workspace when those sessions are still available. A normal
405
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.
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.3.6.dev5"
@@ -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):
@@ -1021,18 +1021,15 @@ class LocalHistoryView:
1021
1021
  return was_active
1022
1022
 
1023
1023
 
1024
- def claim_batched_wheel(
1024
+ def claim_batched_forwarded_wheel(
1025
1025
  event: SgrMouseEvent,
1026
1026
  handled_directions: set[int],
1027
1027
  ) -> bool:
1028
- """Admit at most one wheel tick per direction from one terminal read.
1029
-
1030
- Trackpads commonly report several identical SGR wheel packets together.
1031
- If painting is briefly busy with fresh agent output, those packets can
1032
- accumulate in stdin and then move a frozen local-history viewport many
1033
- lines at once. Bounding each read preserves the established one-line
1034
- local scroll feel without using a timing heuristic; a later read remains
1035
- 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.
1036
1033
  """
1037
1034
  direction = event.wheel_direction
1038
1035
  if direction == 0:
@@ -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
@@ -44,6 +44,29 @@ def _action_legend(
44
44
  return urwid.Text(markup, align=align, wrap=wrap)
45
45
 
46
46
 
47
+ def _action_button(
48
+ keys: str,
49
+ description: str,
50
+ *,
51
+ on_click: Callable[[], None],
52
+ click_key: str,
53
+ ) -> ClickableRow:
54
+ """Render a terminal-safe filled action button with a mouse target."""
55
+ label = urwid.Text(
56
+ [
57
+ ("btn_key", keys),
58
+ ("btn_label", f" = {description}"),
59
+ ],
60
+ align="center",
61
+ wrap="space",
62
+ )
63
+ return ClickableRow(
64
+ urwid.AttrMap(label, "btn_label"),
65
+ on_click=on_click,
66
+ click_key=click_key,
67
+ )
68
+
69
+
47
70
  def _attention_lines(attention: AttentionState | None) -> list:
48
71
  """Compact, generic rendering for known and future attention categories."""
49
72
  if attention is None:
@@ -141,38 +164,37 @@ class QuitConfirmModal(urwid.WidgetWrap):
141
164
  summary = "No running sessions."
142
165
 
143
166
  action_rows: list[urwid.Widget] = [
144
- _action_legend(
145
- [("y / ↵", "quit and kill all sessions")],
146
- align="center",
147
- wrap="space",
167
+ _action_button(
168
+ "y / ↵",
169
+ "hard quit (requires final confirmation)",
170
+ on_click=on_confirm,
171
+ click_key="quit:hard",
148
172
  ),
149
173
  ]
150
174
  if on_soft_quit is not None:
151
- action_rows.append(
152
- ClickableRow(
153
- _action_legend(
154
- [("s", "soft quit (keep sessions alive)")],
155
- align="center",
156
- wrap="space",
157
- ),
175
+ action_rows.extend([
176
+ urwid.Divider(),
177
+ _action_button(
178
+ "s",
179
+ "soft quit (keep sessions alive)",
158
180
  on_click=on_soft_quit,
159
181
  click_key="quit:soft",
160
- )
161
- )
162
- cancel_row = _action_legend(
163
- [("n / Esc", "cancel")],
164
- align="center",
165
- wrap="space",
166
- )
167
- action_rows.append(
168
- ClickableRow(
169
- cancel_row,
182
+ ),
183
+ ])
184
+ action_rows.append(urwid.Divider())
185
+ if on_cancel is not None:
186
+ action_rows.append(_action_button(
187
+ "n / Esc",
188
+ "cancel",
170
189
  on_click=on_cancel,
171
190
  click_key="quit:cancel",
172
- )
173
- if on_cancel is not None
174
- else cancel_row
175
- )
191
+ ))
192
+ else:
193
+ action_rows.append(_action_legend(
194
+ [("n / Esc", "cancel")],
195
+ align="center",
196
+ wrap="space",
197
+ ))
176
198
  self._title = urwid.Text("Quit railmux?", align="center")
177
199
  self._summary = urwid.Text(("live", summary), align="center")
178
200
  self._shared_notice = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.6.dev3
3
+ Version: 0.3.6.dev5
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
@@ -445,7 +445,9 @@ not interrupt it. To leave agents running when you quit Railmux, press `s` for
445
445
  soft quit in the confirmation popup; restarting the same Railmux instance then
446
446
  restores the usable workspace when those sessions are still available. A normal
447
447
  quit ends all running sessions instead and therefore requires a second
448
- confirmation; `y` and Enter keep their existing confirm meaning in both steps.
448
+ confirmation. The first chooser has visible mouse buttons, but the final
449
+ destructive confirmation remains keyboard-only; `y` and Enter keep their
450
+ existing confirm meaning in both steps.
449
451
 
450
452
  All terminals attached to one managed Railmux window view the same UI process.
451
453
  Soft Quit therefore closes that shared UI for every attached view, although it