railmux 0.4.1.dev5__tar.gz → 0.4.1.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. {railmux-0.4.1.dev5/src/railmux.egg-info → railmux-0.4.1.dev6}/PKG-INFO +1 -1
  2. railmux-0.4.1.dev6/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fast_display_input.py +149 -28
  4. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fast_display_server.py +3 -2
  5. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/terminal_screen.py +167 -1
  6. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6/src/railmux.egg-info}/PKG-INFO +1 -1
  7. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_selection.py +60 -0
  8. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_server.py +64 -0
  9. railmux-0.4.1.dev5/src/railmux/__init__.py +0 -1
  10. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/LICENSE +0 -0
  11. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/MANIFEST.in +0 -0
  12. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/README.md +0 -0
  13. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/pyproject.toml +0 -0
  14. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/setup.cfg +0 -0
  15. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/__main__.py +0 -0
  16. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/atomic_file.py +0 -0
  17. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/background_index.py +0 -0
  18. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/cli.py +0 -0
  19. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/codex_index.py +0 -0
  20. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/config.py +0 -0
  21. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/config_cli.py +0 -0
  22. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/diagnostic_contract.py +0 -0
  23. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/diagnostics.py +0 -0
  24. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/discovery.py +0 -0
  25. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/display_transport.py +0 -0
  26. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/entrypoint.py +0 -0
  27. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fast_display_client.py +0 -0
  28. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fast_display_history.py +0 -0
  29. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fast_display_protocol.py +0 -0
  30. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/favorites.py +0 -0
  31. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/fuzzy.py +0 -0
  32. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/help_workspace.py +0 -0
  33. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/launcher.py +0 -0
  34. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/legacy_sessions.py +0 -0
  35. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/local_clipboard.py +0 -0
  36. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/local_open.py +0 -0
  37. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/models.py +0 -0
  38. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/modes.py +0 -0
  39. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/mouse_manager.py +0 -0
  40. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/orphan_marker.py +0 -0
  41. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/pane_surface.py +0 -0
  42. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/path_codec.py +0 -0
  43. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/preview_pager.py +0 -0
  44. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/project_favorites.py +0 -0
  45. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/provider_paths.py +0 -0
  46. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/release_version.py +0 -0
  47. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/remote_config.py +0 -0
  48. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/renames.py +0 -0
  49. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/restart_state.py +0 -0
  50. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/runtime_config.py +0 -0
  51. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/scroll_agent.py +0 -0
  52. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/scroll_manager.py +0 -0
  53. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/selection_isolation.py +0 -0
  54. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/self_update.py +0 -0
  55. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/session_cache.py +0 -0
  56. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/session_index.py +0 -0
  57. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/session_lease.py +0 -0
  58. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/setting_contracts.py +0 -0
  59. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/settings.py +0 -0
  60. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ssh_args.py +0 -0
  61. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ssh_compat.py +0 -0
  62. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ssh_display_diagnostics.py +0 -0
  63. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ssh_doctor.py +0 -0
  64. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ssh_preflight.py +0 -0
  65. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/system_deps.py +0 -0
  66. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/terminal_status.py +0 -0
  67. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tmux_binding_manager.py +0 -0
  68. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tmux_capabilities.py +0 -0
  69. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tmux_ctl.py +0 -0
  70. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tmux_health.py +0 -0
  71. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tmux_server.py +0 -0
  72. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/tool_panes.py +0 -0
  73. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/transcript.py +0 -0
  74. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/__init__.py +0 -0
  75. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/_widgets.py +0 -0
  76. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/app.py +0 -0
  77. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/keymap.py +0 -0
  78. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/modals.py +0 -0
  79. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/projects_pane.py +0 -0
  80. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/running_pane.py +0 -0
  81. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/sessions_pane.py +0 -0
  82. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/sidebar.py +0 -0
  83. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/statusbar.py +0 -0
  84. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/ui/workspace.py +0 -0
  85. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_attach_relay.py +0 -0
  86. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_bootstrap.py +0 -0
  87. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_install_log.py +0 -0
  88. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_msys2.py +0 -0
  89. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_pacman.py +0 -0
  90. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_paths.py +0 -0
  91. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_tmux_lifecycle.py +0 -0
  92. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux/windows_ui_transition.py +0 -0
  93. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux.egg-info/SOURCES.txt +0 -0
  94. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux.egg-info/dependency_links.txt +0 -0
  95. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux.egg-info/entry_points.txt +0 -0
  96. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux.egg-info/requires.txt +0 -0
  97. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/src/railmux.egg-info/top_level.txt +0 -0
  98. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/app_test_harness.py +0 -0
  99. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_agent_workspace.py +0 -0
  100. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_app_codex_placeholder.py +0 -0
  101. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_app_quit.py +0 -0
  102. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_app_restart_restore.py +0 -0
  103. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_app_running_recovery.py +0 -0
  104. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_atomic_file.py +0 -0
  105. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_background_index.py +0 -0
  106. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_cli.py +0 -0
  107. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_code_map.py +0 -0
  108. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_codex_delete.py +0 -0
  109. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_codex_index.py +0 -0
  110. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_codex_lineage.py +0 -0
  111. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_codex_transcript.py +0 -0
  112. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_config.py +0 -0
  113. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_config_cli.py +0 -0
  114. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_denested_transport_benchmark.py +0 -0
  115. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_diagnostics.py +0 -0
  116. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_discovery.py +0 -0
  117. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_display_transport.py +0 -0
  118. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_bootstrap.py +0 -0
  119. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_history.py +0 -0
  120. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_protocol.py +0 -0
  121. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fast_display_terminal.py +0 -0
  122. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_focus_highlight.py +0 -0
  123. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fullscreen_binding.py +0 -0
  124. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_fuzzy.py +0 -0
  125. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_help_agent.py +0 -0
  126. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_help_workspace.py +0 -0
  127. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_history_cleanup.py +0 -0
  128. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_keymap.py +0 -0
  129. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_launcher.py +0 -0
  130. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_layout_profile.py +0 -0
  131. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_legacy_sessions.py +0 -0
  132. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_local_clipboard.py +0 -0
  133. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_local_open.py +0 -0
  134. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_modals.py +0 -0
  135. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_mode_selection.py +0 -0
  136. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_modes.py +0 -0
  137. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_mouse_manager.py +0 -0
  138. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_new_project.py +0 -0
  139. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_options.py +0 -0
  140. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_orphan_recovery.py +0 -0
  141. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_pane_surface.py +0 -0
  142. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_path_codec.py +0 -0
  143. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_preview_pager.py +0 -0
  144. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_project_favorites.py +0 -0
  145. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_projects_pane.py +0 -0
  146. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_provider_paths.py +0 -0
  147. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_release_notes.py +0 -0
  148. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_release_version.py +0 -0
  149. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_release_workflow.py +0 -0
  150. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_remote_config.py +0 -0
  151. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_renames.py +0 -0
  152. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_restart_state.py +0 -0
  153. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_running_pane.py +0 -0
  154. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_running_sort.py +0 -0
  155. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_runtime_config.py +0 -0
  156. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_scroll_agent.py +0 -0
  157. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_scroll_manager.py +0 -0
  158. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_selection_isolation.py +0 -0
  159. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_self_update.py +0 -0
  160. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_session_cache.py +0 -0
  161. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_session_index.py +0 -0
  162. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_session_lease.py +0 -0
  163. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_sessions_pane.py +0 -0
  164. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_setting_contracts.py +0 -0
  165. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_settings.py +0 -0
  166. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_sidebar.py +0 -0
  167. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_ssh_args.py +0 -0
  168. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_ssh_compat.py +0 -0
  169. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_ssh_display_benchmark.py +0 -0
  170. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_ssh_display_diagnostics.py +0 -0
  171. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_ssh_doctor.py +0 -0
  172. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_status.py +0 -0
  173. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_statusbar.py +0 -0
  174. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_system_deps.py +0 -0
  175. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_terminal_size.py +0 -0
  176. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_terminal_status.py +0 -0
  177. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_termux_local_touch.py +0 -0
  178. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_binding_manager.py +0 -0
  179. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_capabilities.py +0 -0
  180. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_ctl.py +0 -0
  181. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_health.py +0 -0
  182. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_integration.py +0 -0
  183. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_server.py +0 -0
  184. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tmux_status.py +0 -0
  185. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_tool_panes.py +0 -0
  186. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_transcript.py +0 -0
  187. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_utf8_state.py +0 -0
  188. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_web_demo_recorder.py +0 -0
  189. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_wheel_smoke.py +0 -0
  190. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_widgets.py +0 -0
  191. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_attach_relay.py +0 -0
  192. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_bootstrap.py +0 -0
  193. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_install_log.py +0 -0
  194. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_msys2.py +0 -0
  195. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_pacman.py +0 -0
  196. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_paths.py +0 -0
  197. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_synchronized_screen.py +0 -0
  198. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_tmux_lifecycle.py +0 -0
  199. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_ui_transition.py +0 -0
  200. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_windows_wrapper_contract.py +0 -0
  201. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tests/test_yolo_prompt.py +0 -0
  202. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tools/denested_transport_benchmark.py +0 -0
  203. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tools/release_notes.py +0 -0
  204. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tools/wheel_smoke.py +0 -0
  205. {railmux-0.4.1.dev5 → railmux-0.4.1.dev6}/tools/windows_archive_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.1.dev5
3
+ Version: 0.4.1.dev6
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1.dev6"
@@ -22,6 +22,9 @@ from railmux.fast_display_protocol import (
22
22
 
23
23
  _SGR_MOUSE_PREFIX = b"\x1b[<"
24
24
  _SGR_STYLE_RE = re.compile(rb"\x1b\[[0-9;]*m")
25
+ _OSC8_LINK_RE = re.compile(
26
+ rb"\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)"
27
+ )
25
28
  _PAGE_UP = b"\x1b[5~"
26
29
  _PAGE_DOWN = b"\x1b[6~"
27
30
  _BRACKETED_PASTE_BEGIN = b"\x1b[200~"
@@ -505,27 +508,62 @@ def _display_width(character: str) -> int:
505
508
  return max(0, pyte.screens.wcwidth(character))
506
509
 
507
510
 
508
- def _plain_display_cells(line: bytes, width: int) -> tuple[str | None, ...]:
509
- """Decode one server-rendered SGR row into bounded display cells."""
510
- plain = _SGR_STYLE_RE.sub(b"", line).decode("utf-8", errors="replace")
511
+ def _validated_osc8_url(value: bytes) -> str | None:
512
+ if not value or len(value) > 8192:
513
+ return None
514
+ try:
515
+ decoded = value.decode("utf-8")
516
+ parsed = urlsplit(decoded)
517
+ hostname = parsed.hostname
518
+ except (UnicodeDecodeError, ValueError):
519
+ return None
520
+ if (
521
+ parsed.scheme.lower() not in ("http", "https")
522
+ or hostname is None
523
+ or any(ord(character) < 0x20 or character == "\x7f" for character in decoded)
524
+ ):
525
+ return None
526
+ return decoded
527
+
528
+
529
+ def _plain_display_row(
530
+ line: bytes,
531
+ width: int,
532
+ ) -> tuple[tuple[str | None, ...], tuple[str | None, ...]]:
533
+ """Decode one rendered row and retain bounded OSC 8 targets per cell."""
511
534
  cells: list[str | None] = [" "] * width
535
+ hyperlinks: list[str | None] = [None] * width
512
536
  column = 0
513
- for character in plain:
514
- cell_width = _display_width(character)
515
- if cell_width == 0:
516
- previous = column - 1
517
- while previous >= 0 and cells[previous] is None:
518
- previous -= 1
519
- if previous >= 0:
520
- cells[previous] = (cells[previous] or "") + character
521
- continue
522
- if column >= width or column + cell_width > width:
523
- break
524
- cells[column] = character
525
- for continuation in range(1, cell_width):
526
- cells[column + continuation] = None
527
- column += cell_width
528
- return tuple(cells)
537
+ active_hyperlink: str | None = None
538
+
539
+ def append_visible(data: bytes) -> None:
540
+ nonlocal column
541
+ plain = _SGR_STYLE_RE.sub(b"", data).decode("utf-8", errors="replace")
542
+ for character in plain:
543
+ cell_width = _display_width(character)
544
+ if cell_width == 0:
545
+ previous = column - 1
546
+ while previous >= 0 and cells[previous] is None:
547
+ previous -= 1
548
+ if previous >= 0:
549
+ cells[previous] = (cells[previous] or "") + character
550
+ continue
551
+ if column >= width or column + cell_width > width:
552
+ return
553
+ cells[column] = character
554
+ hyperlinks[column] = active_hyperlink
555
+ for continuation in range(1, cell_width):
556
+ cells[column + continuation] = None
557
+ hyperlinks[column + continuation] = active_hyperlink
558
+ column += cell_width
559
+
560
+ offset = 0
561
+ for match in _OSC8_LINK_RE.finditer(line):
562
+ append_visible(line[offset : match.start()])
563
+ active_hyperlink = _validated_osc8_url(match.group(1))
564
+ offset = match.end()
565
+ append_visible(line[offset:])
566
+ return tuple(cells), tuple(hyperlinks)
529
567
 
530
568
 
531
569
  _CLICK_TOKEN_RE = re.compile(r"""[^\s<>"'`]+""")
@@ -719,23 +757,97 @@ def click_target_at(
719
757
  return None
720
758
 
721
759
 
722
- def _pane_cells(
760
+ def _pane_rows(
723
761
  source: SelectionSource,
724
762
  *,
725
763
  start: int = 0,
726
764
  end: int | None = None,
727
- ) -> tuple[tuple[str | None, ...], ...]:
728
- """Decode pane-local cells from either full-screen or history rows."""
765
+ ) -> tuple[
766
+ tuple[tuple[str | None, ...], ...],
767
+ tuple[tuple[str | None, ...], ...],
768
+ ]:
769
+ """Decode pane-local cells and OSC 8 targets in one bounded pass."""
729
770
  route = source.route
730
771
  if end is None:
731
772
  end = route.height
732
773
  decode_width = source.row_x_offset + route.width
733
- decoded: list[tuple[str | None, ...]] = []
774
+ decoded_cells: list[tuple[str | None, ...]] = []
775
+ decoded_hyperlinks: list[tuple[str | None, ...]] = []
734
776
  for index in range(start, min(end, route.height)):
735
777
  line = source.rows[index] if index < len(source.rows) else b""
736
- cells = _plain_display_cells(line, decode_width)
737
- decoded.append(cells[source.row_x_offset : source.row_x_offset + route.width])
738
- return tuple(decoded)
778
+ cells, hyperlinks = _plain_display_row(line, decode_width)
779
+ span = slice(source.row_x_offset, source.row_x_offset + route.width)
780
+ decoded_cells.append(cells[span])
781
+ decoded_hyperlinks.append(hyperlinks[span])
782
+ return tuple(decoded_cells), tuple(decoded_hyperlinks)
783
+
784
+
785
+ def _pane_cells(
786
+ source: SelectionSource,
787
+ *,
788
+ start: int = 0,
789
+ end: int | None = None,
790
+ ) -> tuple[tuple[str | None, ...], ...]:
791
+ """Decode pane-local cells from either full-screen or history rows."""
792
+ return _pane_rows(source, start=start, end=end)[0]
793
+
794
+
795
+ def _osc8_target(
796
+ rows: tuple[tuple[str | None, ...], ...],
797
+ hyperlinks: tuple[tuple[str | None, ...], ...],
798
+ row: int,
799
+ column: int,
800
+ *,
801
+ route: HistorySnapshot,
802
+ ) -> ClickTarget | None:
803
+ """Resolve one exact OSC 8 target without reconstructing its label."""
804
+ if not (0 <= row < len(rows) and 0 <= row < len(hyperlinks)):
805
+ return None
806
+ if not 0 <= column < len(hyperlinks[row]):
807
+ return None
808
+ value = hyperlinks[row][column]
809
+ if value is None:
810
+ return None
811
+
812
+ segments: list[SelectionSegment] = []
813
+ first = row
814
+ while first > 0 and value in hyperlinks[first - 1]:
815
+ first -= 1
816
+ last = row
817
+ while last + 1 < len(hyperlinks) and value in hyperlinks[last + 1]:
818
+ last += 1
819
+ for row_index in range(first, last + 1):
820
+ link_row = hyperlinks[row_index]
821
+ cell_row = rows[row_index]
822
+ run_start: int | None = None
823
+ for cell_index in range(len(link_row) + 1):
824
+ linked = cell_index < len(link_row) and link_row[cell_index] == value
825
+ if linked and run_start is None:
826
+ run_start = cell_index
827
+ if not linked and run_start is not None:
828
+ text = "".join(
829
+ cell or "" for cell in cell_row[run_start:cell_index]
830
+ ).encode("utf-8")
831
+ if text:
832
+ segments.append(
833
+ (
834
+ route.y + row_index,
835
+ route.x + run_start,
836
+ text,
837
+ )
838
+ )
839
+ run_start = None
840
+ if not segments:
841
+ return None
842
+ return ClickTarget(
843
+ "url",
844
+ value,
845
+ route.pane_id or "",
846
+ highlight_row=segments[0][0],
847
+ highlight_column=segments[0][1],
848
+ highlight_text=segments[0][2],
849
+ highlight_segments=tuple(segments),
850
+ )
739
851
 
740
852
 
741
853
  def _rows_are_wrapped(
@@ -1124,10 +1236,14 @@ def _target_in_rows(
1124
1236
  column: int,
1125
1237
  *,
1126
1238
  route: HistorySnapshot,
1239
+ hyperlinks: tuple[tuple[str | None, ...], ...] = (),
1127
1240
  ) -> ClickTarget | None:
1128
1241
  """Resolve a semantic target across one bounded visual-wrap chain."""
1129
1242
  if not 0 <= row < len(rows) or route.width <= 0:
1130
1243
  return None
1244
+ exact = _osc8_target(rows, hyperlinks, row, column, route=route)
1245
+ if exact is not None:
1246
+ return exact
1131
1247
  decorated = _decorated_tool_target(
1132
1248
  rows,
1133
1249
  row,
@@ -1200,6 +1316,7 @@ class LocalTextSelection:
1200
1316
  self._press: SgrMouseEvent | None = None
1201
1317
  self._route: HistorySnapshot | None = None
1202
1318
  self._rows: tuple[tuple[str | None, ...], ...] = ()
1319
+ self._hyperlinks: tuple[tuple[str | None, ...], ...] = ()
1203
1320
  self._anchor: tuple[int, int] | None = None
1204
1321
  self._head: tuple[int, int] | None = None
1205
1322
  self._active = False
@@ -1221,6 +1338,7 @@ class LocalTextSelection:
1221
1338
  self._press = None
1222
1339
  self._route = None
1223
1340
  self._rows = ()
1341
+ self._hyperlinks = ()
1224
1342
  self._anchor = None
1225
1343
  self._head = None
1226
1344
  self._active = False
@@ -1240,8 +1358,9 @@ class LocalTextSelection:
1240
1358
  return None
1241
1359
  start = max(0, row - 7)
1242
1360
  end = min(source.route.height, row + 8)
1361
+ rows, hyperlinks = _pane_rows(source, start=start, end=end)
1243
1362
  return _target_in_rows(
1244
- _pane_cells(source, start=start, end=end),
1363
+ rows,
1245
1364
  row - start,
1246
1365
  column,
1247
1366
  route=replace(
@@ -1249,6 +1368,7 @@ class LocalTextSelection:
1249
1368
  y=source.route.y + start,
1250
1369
  height=end - start,
1251
1370
  ),
1371
+ hyperlinks=hyperlinks,
1252
1372
  )
1253
1373
 
1254
1374
  def hover(
@@ -1352,7 +1472,7 @@ class LocalTextSelection:
1352
1472
  route = source.route
1353
1473
  self._press = event
1354
1474
  self._route = route
1355
- self._rows = _pane_cells(source)
1475
+ self._rows, self._hyperlinks = _pane_rows(source)
1356
1476
  self._anchor = self._point(event, route)
1357
1477
  self._head = self._anchor
1358
1478
  self._active = False
@@ -1385,6 +1505,7 @@ class LocalTextSelection:
1385
1505
  self._anchor[1],
1386
1506
  self._anchor[0],
1387
1507
  route=self._route,
1508
+ hyperlinks=self._hyperlinks,
1388
1509
  )
1389
1510
  if (
1390
1511
  self._semantic_open
@@ -80,6 +80,7 @@ from railmux.fast_display_protocol import (
80
80
  )
81
81
  from railmux.fast_display_history import HistoryCaptureJob, HistoryCaptureWorker
82
82
  from railmux.terminal_screen import (
83
+ HyperlinkTrackingStream,
83
84
  Osc52ClipboardDecoder as _Osc52ClipboardDecoder,
84
85
  ScreenState as _ScreenState,
85
86
  build_screen_update,
@@ -1226,7 +1227,7 @@ def _render_history_lines(
1226
1227
  result independently paintable for the local history overlay.
1227
1228
  """
1228
1229
  screen = pyte.Screen(width, 1)
1229
- stream = pyte.ByteStream(screen)
1230
+ stream = HyperlinkTrackingStream(screen, pyte.ByteStream(screen))
1230
1231
  rendered: list[bytes] = []
1231
1232
  for line in lines:
1232
1233
  screen.buffer[0].clear()
@@ -2206,7 +2207,7 @@ def _serve_attached(
2206
2207
  master_fd: int,
2207
2208
  ) -> int:
2208
2209
  screen = pyte.DiffScreen(width, height)
2209
- stream = pyte.ByteStream(screen)
2210
+ stream = HyperlinkTrackingStream(screen, pyte.ByteStream(screen))
2210
2211
  input_decoder = InputFrameDecoder()
2211
2212
  stdin_fd = sys.stdin.buffer.fileno()
2212
2213
  stdout_fd = sys.stdout.buffer.fileno()
@@ -14,6 +14,8 @@ import re
14
14
  from dataclasses import dataclass
15
15
  from functools import lru_cache
16
16
  from types import SimpleNamespace
17
+ from typing import NamedTuple
18
+ from urllib.parse import urlsplit
17
19
 
18
20
  from railmux.fast_display_protocol import (
19
21
  MAX_CLIPBOARD_BYTES,
@@ -65,6 +67,121 @@ _ANSI_BG = {
65
67
  }
66
68
  _OSC52_PREFIX = b"\033]52;"
67
69
  _OSC52_MAX_ENCODED = ((MAX_CLIPBOARD_BYTES + 2) // 3) * 4
70
+ _OSC8_PREFIX = b"\033]8;"
71
+ _OSC8_MAX_BYTES = 16 * 1024
72
+ _MAX_SCREEN_HYPERLINKS = 256
73
+
74
+
75
+ class _HyperlinkChar(NamedTuple):
76
+ """A pyte-compatible cell with one bounded OSC 8 target."""
77
+
78
+ data: str
79
+ fg: str = "default"
80
+ bg: str = "default"
81
+ bold: bool = False
82
+ italics: bool = False
83
+ underscore: bool = False
84
+ strikethrough: bool = False
85
+ reverse: bool = False
86
+ blink: bool = False
87
+ hyperlink: str | None = None
88
+
89
+
90
+ def _hyperlink_char(char: object, hyperlink: str | None) -> _HyperlinkChar:
91
+ return _HyperlinkChar(
92
+ data=char.data,
93
+ fg=char.fg,
94
+ bg=char.bg,
95
+ bold=char.bold,
96
+ italics=char.italics,
97
+ underscore=char.underscore,
98
+ strikethrough=char.strikethrough,
99
+ reverse=char.reverse,
100
+ blink=char.blink,
101
+ hyperlink=hyperlink,
102
+ )
103
+
104
+
105
+ def _safe_hyperlink(value: bytes) -> str | None:
106
+ if not value or len(value) > 8192:
107
+ return None
108
+ try:
109
+ decoded = value.decode("utf-8")
110
+ except UnicodeDecodeError:
111
+ return None
112
+ if any(ord(character) < 0x20 or character == "\x7f" for character in decoded):
113
+ return None
114
+ try:
115
+ parsed = urlsplit(decoded)
116
+ hostname = parsed.hostname
117
+ except ValueError:
118
+ return None
119
+ if parsed.scheme.lower() not in ("http", "https") or hostname is None:
120
+ return None
121
+ return decoded
122
+
123
+
124
+ class HyperlinkTrackingStream:
125
+ """Preserve bounded HTTP(S) OSC 8 targets in pyte screen cells.
126
+
127
+ pyte intentionally ignores OSC 8. Railmux needs the target independently
128
+ from its often-wrapped display label, so this wrapper consumes only OSC 8,
129
+ updates the extended screen's cell attribute, and passes every other byte
130
+ through unchanged. The parser is incremental and bounded because PTY
131
+ reads may split either terminator.
132
+ """
133
+
134
+ def __init__(self, screen: object, stream: object) -> None:
135
+ self.screen = screen
136
+ self.stream = stream
137
+ self._buffer = bytearray()
138
+
139
+ def _feed_visible(self, data: bytes) -> None:
140
+ if data:
141
+ self.stream.feed(data)
142
+
143
+ def feed(self, data: bytes) -> None:
144
+ if not data:
145
+ return
146
+ self._buffer.extend(data)
147
+ while self._buffer:
148
+ start = self._buffer.find(_OSC8_PREFIX)
149
+ if start < 0:
150
+ keep = 0
151
+ maximum = min(len(self._buffer), len(_OSC8_PREFIX) - 1)
152
+ for length in range(maximum, 0, -1):
153
+ if _OSC8_PREFIX.startswith(self._buffer[-length:]):
154
+ keep = length
155
+ break
156
+ visible_end = len(self._buffer) - keep
157
+ self._feed_visible(bytes(self._buffer[:visible_end]))
158
+ del self._buffer[:visible_end]
159
+ return
160
+ if start:
161
+ self._feed_visible(bytes(self._buffer[:start]))
162
+ del self._buffer[:start]
163
+ bel = self._buffer.find(b"\007", len(_OSC8_PREFIX))
164
+ st = self._buffer.find(b"\033\\", len(_OSC8_PREFIX))
165
+ endings = tuple(
166
+ (position, length)
167
+ for position, length in ((bel, 1), (st, 2))
168
+ if position >= 0
169
+ )
170
+ if not endings:
171
+ if len(self._buffer) <= _OSC8_MAX_BYTES:
172
+ return
173
+ # An unterminated lookalike is ordinary terminal output. Feed
174
+ # one byte and resume scanning without retaining an unbounded
175
+ # attacker-controlled prefix.
176
+ self._feed_visible(bytes(self._buffer[:1]))
177
+ del self._buffer[:1]
178
+ continue
179
+ end, terminator_length = min(endings)
180
+ body = bytes(self._buffer[len(_OSC8_PREFIX) : end])
181
+ del self._buffer[: end + terminator_length]
182
+ _params, separator, target = body.partition(b";")
183
+ hyperlink = _safe_hyperlink(target) if separator else None
184
+ self.screen.set_hyperlink(hyperlink)
68
185
 
69
186
 
70
187
  class Osc52ClipboardDecoder:
@@ -123,9 +240,29 @@ class _ExtendedScreenMixin:
123
240
  _last_graphic_character = ""
124
241
  _character_width = staticmethod(lambda _character: 1)
125
242
 
243
+ @property
244
+ def default_char(self) -> _HyperlinkChar:
245
+ return _hyperlink_char(super().default_char, None)
246
+
126
247
  def reset(self) -> None:
127
248
  self._last_graphic_character = ""
249
+ self._active_hyperlink: str | None = None
250
+ self._known_hyperlinks: dict[str, str] = {}
128
251
  super().reset()
252
+ self.cursor.attrs = _hyperlink_char(self.cursor.attrs, None)
253
+
254
+ def set_hyperlink(self, hyperlink: str | None) -> None:
255
+ if hyperlink is not None:
256
+ known = self._known_hyperlinks.get(hyperlink)
257
+ if known is None:
258
+ if len(self._known_hyperlinks) >= _MAX_SCREEN_HYPERLINKS:
259
+ hyperlink = None
260
+ else:
261
+ self._known_hyperlinks[hyperlink] = hyperlink
262
+ else:
263
+ hyperlink = known
264
+ self._active_hyperlink = hyperlink
265
+ self.cursor.attrs = _hyperlink_char(self.cursor.attrs, hyperlink)
129
266
 
130
267
  def draw(self, data: str) -> None:
131
268
  super().draw(data)
@@ -174,6 +311,10 @@ class _ExtendedScreenMixin:
174
311
  def select_graphic_rendition(self, *attrs: int) -> None:
175
312
  """Retain 256-colour indices instead of baking in pyte's palette."""
176
313
  super().select_graphic_rendition(*attrs)
314
+ self.cursor.attrs = _hyperlink_char(
315
+ self.cursor.attrs,
316
+ self._active_hyperlink,
317
+ )
177
318
  values = list(attrs)
178
319
  indexed: dict[str, int] = {}
179
320
  index = 0
@@ -209,6 +350,14 @@ class _ExtendedScreenMixin:
209
350
  **{key: f"ansi256:{colour}" for key, colour in indexed.items()}
210
351
  )
211
352
 
353
+ def restore_cursor(self) -> None:
354
+ super().restore_cursor()
355
+ # OSC 8 state is not part of DECSC/DECRC cursor attributes.
356
+ self.cursor.attrs = _hyperlink_char(
357
+ self.cursor.attrs,
358
+ self._active_hyperlink,
359
+ )
360
+
212
361
 
213
362
  @lru_cache(maxsize=4)
214
363
  def _build_extended_pyte(pyte: object) -> object:
@@ -333,6 +482,7 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
333
482
  for row_index in range(screen.lines):
334
483
  rendered = [b"\033[0m"]
335
484
  previous_style: tuple[object, ...] | None = None
485
+ previous_hyperlink: str | None = None
336
486
  row = screen.buffer[row_index]
337
487
  rendered_columns = _last_rendered_column(row, screen.columns)
338
488
  continuation_cells = 0
@@ -346,6 +496,17 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
346
496
  continuation_cells = 0
347
497
  continuation_data = ""
348
498
  char = row[column]
499
+ hyperlink = getattr(char, "hyperlink", None)
500
+ if hyperlink != previous_hyperlink:
501
+ if previous_hyperlink is not None:
502
+ rendered.append(b"\033]8;;\033\\")
503
+ if hyperlink is not None:
504
+ rendered.append(
505
+ b"\033]8;;"
506
+ + hyperlink.encode("utf-8", errors="strict")
507
+ + b"\033\\"
508
+ )
509
+ previous_hyperlink = hyperlink
349
510
  style = _style_key(char)
350
511
  if style != previous_style:
351
512
  rendered.append(_style(char))
@@ -366,6 +527,8 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
366
527
  )
367
528
  continuation_cells = max(0, cell_width - 1)
368
529
  continuation_data = char.data
530
+ if previous_hyperlink is not None:
531
+ rendered.append(b"\033]8;;\033\\")
369
532
  rendered.append(b"\033[0m")
370
533
  rendered_rows.append(b"".join(rendered))
371
534
  return tuple(rendered_rows)
@@ -465,7 +628,10 @@ class ScreenProducer:
465
628
  self.width = width
466
629
  self.height = height
467
630
  self.screen = self.pyte.DiffScreen(width, height)
468
- self.stream = self.pyte.ByteStream(self.screen)
631
+ self.stream = HyperlinkTrackingStream(
632
+ self.screen,
633
+ self.pyte.ByteStream(self.screen),
634
+ )
469
635
  self._clipboard = Osc52ClipboardDecoder()
470
636
  self._clipboard_ready: list[bytes] = []
471
637
  self.delivered: ScreenState | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.1.dev5
3
+ Version: 0.4.1.dev6
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -717,6 +717,66 @@ def test_local_text_selection_joins_url_across_codex_ran_decoration():
717
717
  assert action.open_target.value == expected
718
718
 
719
719
 
720
+ def test_local_text_selection_uses_exact_osc8_target_across_table_rows():
721
+ target = "https://jirasw.nvidia.com/browse/TRT-28431"
722
+
723
+ def linked(label: str) -> bytes:
724
+ return (
725
+ b"\033]8;;"
726
+ + target.encode()
727
+ + b"\033\\"
728
+ + label.encode()
729
+ + b"\033]8;;\033\\"
730
+ )
731
+
732
+ visible = ("TRT-28431", "https://", "jirasw.nvidia.co", "m/browse/TRT-28431")
733
+ width = max(len(value) for value in visible)
734
+ route = HistorySnapshot(1, "%8", 7, 3, width, len(visible))
735
+ source = SelectionSource(
736
+ route,
737
+ tuple(linked(value) for value in visible),
738
+ 0,
739
+ )
740
+
741
+ for row, label in enumerate(visible):
742
+ selection = LocalTextSelection()
743
+ event_row = route.y + row + 1
744
+ selection.pointer_event(
745
+ SgrMouseEvent(b"down", 0, route.x + 2, event_row, True),
746
+ source,
747
+ )
748
+ action = selection.pointer_event(
749
+ SgrMouseEvent(b"up", 0, route.x + 2, event_row, False),
750
+ source,
751
+ )
752
+ assert action.open_target is not None, label
753
+ assert action.open_target.kind == "url"
754
+ assert action.open_target.value == target
755
+ assert action.open_target.highlight_segments == tuple(
756
+ (route.y + index, route.x, value.encode())
757
+ for index, value in enumerate(visible)
758
+ )
759
+
760
+
761
+ def test_local_text_selection_does_not_trust_non_http_osc8_target():
762
+ route = HistorySnapshot(1, "%8", 0, 0, 24, 1)
763
+ source = SelectionSource(
764
+ route,
765
+ (b"\033]8;;file:///tmp/report\033\\open report\033]8;;\033\\",),
766
+ 0,
767
+ )
768
+ selection = LocalTextSelection()
769
+
770
+ selection.pointer_event(SgrMouseEvent(b"down", 0, 2, 1, True), source)
771
+ action = selection.pointer_event(
772
+ SgrMouseEvent(b"up", 0, 2, 1, False),
773
+ source,
774
+ )
775
+
776
+ assert action.open_target is None
777
+ assert action.replay_events
778
+
779
+
720
780
  def test_local_text_selection_joins_path_across_codex_ran_decoration():
721
781
  first = "• Ran /tmp/railmux-published-0.4.1.dev1-"
722
782
  second = " │ 20260816/bin/pip --isolated"
@@ -1900,6 +1900,70 @@ def test_server_renderer_keeps_visible_styled_trailing_blanks():
1900
1900
  assert b";41m " in rendered
1901
1901
 
1902
1902
 
1903
+ def test_terminal_model_preserves_fragmented_http_osc8_targets_in_rows():
1904
+ pyte = pytest.importorskip("pyte")
1905
+ terminal = fast_display_server._extended_pyte(pyte)
1906
+ screen = terminal.Screen(24, 2)
1907
+ stream = fast_display_server.HyperlinkTrackingStream(
1908
+ screen,
1909
+ terminal.ByteStream(screen),
1910
+ )
1911
+ target = b"https://jirasw.nvidia.com/browse/TRT-28431"
1912
+
1913
+ for chunk in (
1914
+ b"\033]8;i",
1915
+ b"d=issue;" + target[:20],
1916
+ target[20:] + b"\033",
1917
+ b"\\TRT-28431\033]8;;\033\\",
1918
+ ):
1919
+ stream.feed(chunk)
1920
+
1921
+ rendered = render_rows(screen)[0]
1922
+ assert b"TRT-28431" in rendered
1923
+ assert b"\033]8;;" + target + b"\033\\" in rendered
1924
+ assert screen.buffer[0][0].hyperlink == target.decode()
1925
+ assert screen.buffer[0][8].hyperlink == target.decode()
1926
+ assert screen.buffer[0][9].hyperlink is None
1927
+
1928
+
1929
+ def test_terminal_model_drops_unsafe_or_stale_osc8_targets():
1930
+ pyte = pytest.importorskip("pyte")
1931
+ terminal = fast_display_server._extended_pyte(pyte)
1932
+ screen = terminal.Screen(8, 1)
1933
+ stream = fast_display_server.HyperlinkTrackingStream(
1934
+ screen,
1935
+ terminal.ByteStream(screen),
1936
+ )
1937
+
1938
+ stream.feed(b"\033]8;;https://safe.test\007ok\033]8;;\007")
1939
+ stream.feed(b"\033[1Gplain")
1940
+ stream.feed(b"\033]8;;file:///tmp/nope\007x\033]8;;\007")
1941
+
1942
+ rendered = render_rows(screen)[0]
1943
+ assert b"https://safe.test" not in rendered
1944
+ assert b"file:///tmp/nope" not in rendered
1945
+ assert all(screen.buffer[0][column].hyperlink is None for column in range(6))
1946
+
1947
+
1948
+ def test_terminal_model_bounds_distinct_live_osc8_targets():
1949
+ pyte = pytest.importorskip("pyte")
1950
+ terminal = fast_display_server._extended_pyte(pyte)
1951
+ screen = terminal.Screen(300, 1)
1952
+ stream = fast_display_server.HyperlinkTrackingStream(
1953
+ screen,
1954
+ terminal.ByteStream(screen),
1955
+ )
1956
+
1957
+ for index in range(257):
1958
+ stream.feed(
1959
+ f"\033]8;;https://example.test/{index}\007x\033]8;;\007".encode()
1960
+ )
1961
+
1962
+ assert len(screen._known_hyperlinks) == 256
1963
+ assert screen.buffer[0][255].hyperlink == "https://example.test/255"
1964
+ assert screen.buffer[0][256].hyperlink is None
1965
+
1966
+
1903
1967
  def test_server_renderer_collapses_reported_repeated_cjk_physical_cells():
1904
1968
  text = "基本通了,但发现一个真 bug:"
1905
1969
  cells = [
@@ -1 +0,0 @@
1
- __version__ = "0.4.1.dev5"
File without changes
File without changes
File without changes