railmux 0.4.2.dev5__tar.gz → 0.4.2.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 (208) hide show
  1. {railmux-0.4.2.dev5/src/railmux.egg-info → railmux-0.4.2.dev6}/PKG-INFO +1 -1
  2. railmux-0.4.2.dev6/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fast_display_server.py +42 -21
  4. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/transcript.py +272 -43
  5. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6/src/railmux.egg-info}/PKG-INFO +1 -1
  6. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_server.py +32 -4
  7. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_transcript.py +76 -0
  8. railmux-0.4.2.dev5/src/railmux/__init__.py +0 -1
  9. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/LICENSE +0 -0
  10. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/MANIFEST.in +0 -0
  11. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/README.md +0 -0
  12. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/pyproject.toml +0 -0
  13. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/setup.cfg +0 -0
  14. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/__main__.py +0 -0
  15. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/atomic_file.py +0 -0
  16. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/background_index.py +0 -0
  17. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/cli.py +0 -0
  18. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/codex_index.py +0 -0
  19. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/config.py +0 -0
  20. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/config_cli.py +0 -0
  21. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/diagnostic_contract.py +0 -0
  22. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/diagnostics.py +0 -0
  23. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/discovery.py +0 -0
  24. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/display_transport.py +0 -0
  25. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/entrypoint.py +0 -0
  26. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fast_display_client.py +0 -0
  27. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fast_display_history.py +0 -0
  28. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fast_display_input.py +0 -0
  29. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fast_display_protocol.py +0 -0
  30. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/favorites.py +0 -0
  31. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/fuzzy.py +0 -0
  32. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/help_workspace.py +0 -0
  33. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/image_paste.py +0 -0
  34. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/launcher.py +0 -0
  35. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/legacy_sessions.py +0 -0
  36. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/local_clipboard.py +0 -0
  37. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/local_open.py +0 -0
  38. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/models.py +0 -0
  39. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/modes.py +0 -0
  40. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/mouse_manager.py +0 -0
  41. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/orphan_marker.py +0 -0
  42. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/pane_surface.py +0 -0
  43. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/path_codec.py +0 -0
  44. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/preview_pager.py +0 -0
  45. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/project_favorites.py +0 -0
  46. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/provider_paths.py +0 -0
  47. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/release_version.py +0 -0
  48. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/remote_config.py +0 -0
  49. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/renames.py +0 -0
  50. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/restart_state.py +0 -0
  51. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/runtime_config.py +0 -0
  52. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/scroll_agent.py +0 -0
  53. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/scroll_manager.py +0 -0
  54. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/selection_isolation.py +0 -0
  55. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/self_update.py +0 -0
  56. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/session_cache.py +0 -0
  57. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/session_index.py +0 -0
  58. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/session_lease.py +0 -0
  59. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/setting_contracts.py +0 -0
  60. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/settings.py +0 -0
  61. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ssh_args.py +0 -0
  62. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ssh_compat.py +0 -0
  63. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ssh_display_diagnostics.py +0 -0
  64. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ssh_doctor.py +0 -0
  65. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ssh_preflight.py +0 -0
  66. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/system_deps.py +0 -0
  67. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/terminal_screen.py +0 -0
  68. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/terminal_status.py +0 -0
  69. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tmux_binding_manager.py +0 -0
  70. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tmux_capabilities.py +0 -0
  71. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tmux_ctl.py +0 -0
  72. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tmux_health.py +0 -0
  73. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tmux_server.py +0 -0
  74. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/tool_panes.py +0 -0
  75. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/__init__.py +0 -0
  76. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/_widgets.py +0 -0
  77. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/app.py +0 -0
  78. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/keymap.py +0 -0
  79. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/modals.py +0 -0
  80. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/projects_pane.py +0 -0
  81. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/running_pane.py +0 -0
  82. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/sessions_pane.py +0 -0
  83. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/sidebar.py +0 -0
  84. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/statusbar.py +0 -0
  85. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/ui/workspace.py +0 -0
  86. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_attach_relay.py +0 -0
  87. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_bootstrap.py +0 -0
  88. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_install_log.py +0 -0
  89. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_msys2.py +0 -0
  90. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_pacman.py +0 -0
  91. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_paths.py +0 -0
  92. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_tmux_lifecycle.py +0 -0
  93. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux/windows_ui_transition.py +0 -0
  94. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux.egg-info/SOURCES.txt +0 -0
  95. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux.egg-info/dependency_links.txt +0 -0
  96. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux.egg-info/entry_points.txt +0 -0
  97. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux.egg-info/requires.txt +0 -0
  98. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/src/railmux.egg-info/top_level.txt +0 -0
  99. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/app_test_harness.py +0 -0
  100. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_agent_workspace.py +0 -0
  101. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_app_codex_placeholder.py +0 -0
  102. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_app_quit.py +0 -0
  103. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_app_restart_restore.py +0 -0
  104. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_app_running_recovery.py +0 -0
  105. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_atomic_file.py +0 -0
  106. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_background_index.py +0 -0
  107. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_claude_continuation.py +0 -0
  108. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_cli.py +0 -0
  109. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_code_map.py +0 -0
  110. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_codex_delete.py +0 -0
  111. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_codex_index.py +0 -0
  112. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_codex_lineage.py +0 -0
  113. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_codex_transcript.py +0 -0
  114. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_config.py +0 -0
  115. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_config_cli.py +0 -0
  116. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_denested_transport_benchmark.py +0 -0
  117. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_diagnostics.py +0 -0
  118. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_discovery.py +0 -0
  119. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_display_transport.py +0 -0
  120. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_bootstrap.py +0 -0
  121. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_history.py +0 -0
  122. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_protocol.py +0 -0
  123. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_selection.py +0 -0
  124. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fast_display_terminal.py +0 -0
  125. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_focus_highlight.py +0 -0
  126. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fullscreen_binding.py +0 -0
  127. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_fuzzy.py +0 -0
  128. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_help_agent.py +0 -0
  129. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_help_workspace.py +0 -0
  130. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_history_cleanup.py +0 -0
  131. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_image_paste.py +0 -0
  132. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_keymap.py +0 -0
  133. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_launcher.py +0 -0
  134. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_layout_profile.py +0 -0
  135. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_legacy_sessions.py +0 -0
  136. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_local_clipboard.py +0 -0
  137. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_local_open.py +0 -0
  138. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_modals.py +0 -0
  139. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_mode_selection.py +0 -0
  140. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_modes.py +0 -0
  141. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_mouse_manager.py +0 -0
  142. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_new_project.py +0 -0
  143. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_options.py +0 -0
  144. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_orphan_recovery.py +0 -0
  145. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_pane_surface.py +0 -0
  146. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_path_codec.py +0 -0
  147. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_preview_pager.py +0 -0
  148. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_project_favorites.py +0 -0
  149. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_projects_pane.py +0 -0
  150. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_provider_paths.py +0 -0
  151. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_release_notes.py +0 -0
  152. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_release_version.py +0 -0
  153. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_release_workflow.py +0 -0
  154. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_remote_config.py +0 -0
  155. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_renames.py +0 -0
  156. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_restart_state.py +0 -0
  157. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_running_pane.py +0 -0
  158. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_running_sort.py +0 -0
  159. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_runtime_config.py +0 -0
  160. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_scroll_agent.py +0 -0
  161. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_scroll_manager.py +0 -0
  162. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_selection_isolation.py +0 -0
  163. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_self_update.py +0 -0
  164. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_session_cache.py +0 -0
  165. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_session_index.py +0 -0
  166. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_session_lease.py +0 -0
  167. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_sessions_pane.py +0 -0
  168. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_setting_contracts.py +0 -0
  169. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_settings.py +0 -0
  170. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_sidebar.py +0 -0
  171. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_ssh_args.py +0 -0
  172. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_ssh_compat.py +0 -0
  173. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_ssh_display_benchmark.py +0 -0
  174. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_ssh_display_diagnostics.py +0 -0
  175. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_ssh_doctor.py +0 -0
  176. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_status.py +0 -0
  177. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_statusbar.py +0 -0
  178. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_system_deps.py +0 -0
  179. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_terminal_size.py +0 -0
  180. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_terminal_status.py +0 -0
  181. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_termux_local_touch.py +0 -0
  182. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_binding_manager.py +0 -0
  183. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_capabilities.py +0 -0
  184. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_ctl.py +0 -0
  185. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_health.py +0 -0
  186. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_integration.py +0 -0
  187. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_server.py +0 -0
  188. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tmux_status.py +0 -0
  189. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_tool_panes.py +0 -0
  190. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_utf8_state.py +0 -0
  191. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_web_demo_recorder.py +0 -0
  192. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_wheel_smoke.py +0 -0
  193. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_widgets.py +0 -0
  194. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_attach_relay.py +0 -0
  195. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_bootstrap.py +0 -0
  196. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_install_log.py +0 -0
  197. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_msys2.py +0 -0
  198. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_pacman.py +0 -0
  199. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_paths.py +0 -0
  200. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_synchronized_screen.py +0 -0
  201. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_tmux_lifecycle.py +0 -0
  202. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_ui_transition.py +0 -0
  203. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_windows_wrapper_contract.py +0 -0
  204. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tests/test_yolo_prompt.py +0 -0
  205. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tools/denested_transport_benchmark.py +0 -0
  206. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tools/release_notes.py +0 -0
  207. {railmux-0.4.2.dev5 → railmux-0.4.2.dev6}/tools/wheel_smoke.py +0 -0
  208. {railmux-0.4.2.dev5 → railmux-0.4.2.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.2.dev5
3
+ Version: 0.4.2.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.2.dev6"
@@ -374,7 +374,7 @@ class _TranscriptFormatCacheEntry:
374
374
  _TRANSCRIPT_CACHE: OrderedDict[tuple[str, int], _TranscriptCacheEntry] = OrderedDict()
375
375
  _TRANSCRIPT_CACHE_LIMIT = 4
376
376
  _TRANSCRIPT_FORMAT_CACHE: OrderedDict[
377
- tuple[str, str], _TranscriptFormatCacheEntry
377
+ tuple[str, str, int | None], _TranscriptFormatCacheEntry
378
378
  ] = OrderedDict()
379
379
  _TRANSCRIPT_FORMAT_CACHE_LIMIT = 2
380
380
  _TRANSCRIPT_FORMAT_CACHE_MAX_CHARS = 4 * 1024 * 1024
@@ -1699,7 +1699,9 @@ def _transcript_rows(
1699
1699
  ):
1700
1700
  _TRANSCRIPT_CACHE.move_to_end(key)
1701
1701
  return cached
1702
- format_key = (str(source.path), source.provider)
1702
+ # Claude history is pre-wrapped to the pane; Codex text is not.
1703
+ format_width = width if source.provider == "claude" else None
1704
+ format_key = (str(source.path), source.provider, format_width)
1703
1705
  formatted_entry = _TRANSCRIPT_FORMAT_CACHE.get(format_key)
1704
1706
  if formatted_entry is not None and formatted_entry.identity == identity:
1705
1707
  _TRANSCRIPT_FORMAT_CACHE.move_to_end(format_key)
@@ -1715,6 +1717,7 @@ def _transcript_rows(
1715
1717
  io.StringIO(raw),
1716
1718
  source.provider,
1717
1719
  claude_native=source.provider == "claude",
1720
+ width=format_width,
1718
1721
  )
1719
1722
  )
1720
1723
  if len(formatted) <= _TRANSCRIPT_FORMAT_CACHE_MAX_CHARS:
@@ -1773,10 +1776,12 @@ def _transcript_rows_before_screen(
1773
1776
 
1774
1777
  Transcript-backed history appends the live viewport after the transcript,
1775
1778
  whose tail usually holds the same newest message. Locate the screen's
1776
- text in the transcript tail through distinctive anchor rows and cut the
1777
- transcript where the screen begins. Two anchors must agree on that start
1778
- unless one long anchor is the only candidate; without a credible match
1779
- every transcript row is kept.
1779
+ text in the transcript tail through distinctive anchor rows: the largest
1780
+ group of anchors that agree on where the screen starts wins, so screen
1781
+ regions the transcript renders differently (such as a diff) cannot veto
1782
+ the match. A lone anchor must be long and unopposed. From the earliest agreeing anchor
1783
+ the cut walks back over screen rows that also precede it in the
1784
+ transcript; without a credible match every transcript row is kept.
1780
1785
  """
1781
1786
  total = len(transcript_rows)
1782
1787
  window = max(
@@ -1791,26 +1796,42 @@ def _transcript_rows_before_screen(
1791
1796
  parts.append(text)
1792
1797
  offset += len(text)
1793
1798
  haystack = "".join(parts)
1799
+ screen_texts = [_overlap_text(row) for row in screen_rows]
1794
1800
  screen_offset = 0
1795
- starts: list[tuple[int, int]] = []
1796
- for row in screen_rows:
1797
- text = _overlap_text(row)
1801
+ # (screen row, transcript position, anchor length, implied screen start)
1802
+ anchors: list[tuple[int, int, int, int]] = []
1803
+ for index, text in enumerate(screen_texts):
1798
1804
  if len(text) >= _OVERLAP_ANCHOR_CHARS and haystack.count(text) == 1:
1799
- starts.append((haystack.index(text) - screen_offset, len(text)))
1800
- if len(starts) == 2:
1801
- break
1805
+ position = haystack.index(text)
1806
+ anchors.append(
1807
+ (index, position, len(text), position - screen_offset))
1802
1808
  screen_offset += len(text)
1803
- if not starts:
1809
+ if not anchors:
1804
1810
  return total
1805
- if len(starts) == 1:
1806
- if starts[0][1] < _OVERLAP_SINGLE_ANCHOR_CHARS:
1807
- return total
1808
- else:
1811
+
1812
+ def agreeing(anchor: tuple[int, int, int, int]) -> list:
1809
1813
  # Allow small rendering differences (list numbering, bullets).
1810
- tolerance = 16 + abs(starts[0][0]) // 20
1811
- if abs(starts[0][0] - starts[1][0]) > tolerance:
1812
- return total
1813
- start = max(0, starts[0][0])
1814
+ tolerance = 16 + abs(anchor[3]) // 20
1815
+ return [
1816
+ other for other in anchors
1817
+ if abs(other[3] - anchor[3]) <= tolerance
1818
+ ]
1819
+
1820
+ group = max(
1821
+ (agreeing(anchor) for anchor in anchors),
1822
+ key=lambda members: (len(members), max(m[3] for m in members)),
1823
+ )
1824
+ if len(group) == 1 and (
1825
+ len(anchors) > 1 or group[0][2] < _OVERLAP_SINGLE_ANCHOR_CHARS
1826
+ ):
1827
+ return total
1828
+ index, start, _length, _implied = min(group)
1829
+ for text in reversed(screen_texts[:index]):
1830
+ if not text:
1831
+ continue
1832
+ if start < len(text) or haystack[start - len(text):start] != text:
1833
+ break
1834
+ start -= len(text)
1814
1835
  # Keep a row that begins before the screen: it may carry text the screen
1815
1836
  # no longer shows, and a partial duplicate is better than a loss.
1816
1837
  keep = len(row_starts)
@@ -236,6 +236,186 @@ _MD_INLINE_RE = re.compile(
236
236
  r"|__(?=\S)(.+?)(?<=\S)__"
237
237
  r"|(?<![\w*])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![\w*])"
238
238
  )
239
+ _MD_TABLE_SEPARATOR_RE = re.compile(
240
+ r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
241
+ _MD_TABLE_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
242
+ _LIST_HANG_RE = re.compile(r" *(?:(?:[-*+]|\d+[.)]) +)?")
243
+ _SGR_RE = re.compile(r"\x1b\[[0-9;]*m")
244
+ _WRAP_TOKEN_RE = re.compile(r"\x1b\[[0-9;]*m| +|[^ \x1b]+")
245
+ _TASK_SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.DOTALL)
246
+
247
+ _char_width_function = None
248
+
249
+
250
+ def _char_width(char: str) -> int:
251
+ """Terminal cell width of *char*, matching pyte's ``wcwidth`` when present."""
252
+ global _char_width_function
253
+ if _char_width_function is None:
254
+ try:
255
+ from wcwidth import wcwidth as _char_width_function
256
+ except ImportError:
257
+ import unicodedata
258
+
259
+ def _char_width_function(value: str) -> int:
260
+ if unicodedata.combining(value):
261
+ return 0
262
+ return 2 if unicodedata.east_asian_width(value) in "WF" else 1
263
+ return max(0, _char_width_function(char))
264
+
265
+
266
+ def _visible_width(text: str) -> int:
267
+ # Claude pads an emoji-presentation selector (as in "⚠️") to two cells.
268
+ return sum(
269
+ 1 if char == "\ufe0f" else _char_width(char)
270
+ for char in _SGR_RE.sub("", text)
271
+ )
272
+
273
+
274
+ def _wrap_styled(line: str, width: int, hang: int) -> list[str]:
275
+ """Word-wrap one styled line the way Claude's Ink renderer does.
276
+
277
+ Rows break at spaces; only a word wider than a whole row is split. Rows
278
+ after the first are indented by *hang* columns so list items and message
279
+ bodies keep their hanging indent instead of restarting at column zero.
280
+ """
281
+ line = line.expandtabs(8)
282
+ if width <= 0 or _visible_width(line) <= width:
283
+ return [line]
284
+ if hang >= width // 2:
285
+ hang = 0
286
+ rows: list[str] = []
287
+ row: list[str] = []
288
+ column = 0
289
+
290
+ def newline() -> None:
291
+ nonlocal row, column
292
+ rows.append("".join(row).rstrip(" "))
293
+ row = [" " * hang]
294
+ column = hang
295
+
296
+ for token in _WRAP_TOKEN_RE.findall(line):
297
+ if token.startswith("\x1b"):
298
+ row.append(token)
299
+ continue
300
+ token_width = _visible_width(token)
301
+ if token.startswith(" "):
302
+ if column + token_width <= width:
303
+ row.append(token)
304
+ column += token_width
305
+ else:
306
+ newline()
307
+ continue
308
+ if (
309
+ column + token_width > width
310
+ and column > hang
311
+ and token_width <= width - hang
312
+ ):
313
+ newline()
314
+ for char in token:
315
+ char_width = _char_width(char)
316
+ if column + char_width > width and column > hang:
317
+ newline()
318
+ row.append(char)
319
+ column += char_width
320
+ rows.append("".join(row).rstrip(" "))
321
+ return rows
322
+
323
+
324
+ def _hang_for(row: str, prefix: int) -> int:
325
+ """Hanging indent for a row whose own content starts after *prefix* cells."""
326
+ content = _SGR_RE.sub("", row)[prefix:]
327
+ return prefix + len(_LIST_HANG_RE.match(content).group(0))
328
+
329
+
330
+ def _close_fragments(fragments: list[str]) -> list[str]:
331
+ """Make each wrapped fragment carry and close its own SGR state.
332
+
333
+ Table cells sit beside other cells on one row, so a style that continues
334
+ onto a cell's next fragment must not leak across the borders between.
335
+ """
336
+ closed: list[str] = []
337
+ active: list[str] = []
338
+ for fragment in fragments:
339
+ prefix = "".join(active)
340
+ for code in _SGR_RE.findall(fragment):
341
+ if code == RESET:
342
+ active.clear()
343
+ else:
344
+ active.append(code)
345
+ closed.append(f"{prefix}{fragment}{RESET}")
346
+ return closed
347
+
348
+
349
+ def _table_cells(line: str) -> list[str]:
350
+ stripped = line.strip()
351
+ if stripped.startswith("|"):
352
+ stripped = stripped[1:]
353
+ if stripped.endswith("|") and not stripped.endswith("\\|"):
354
+ stripped = stripped[:-1]
355
+ return [
356
+ cell.strip().replace("\\|", "|")
357
+ for cell in _MD_TABLE_CELL_SPLIT_RE.split(stripped)
358
+ ]
359
+
360
+
361
+ def _markdown_table(lines: list[str], width: int) -> list[str] | None:
362
+ """Draw a markdown table as Claude does: a box whose cells wrap to fit.
363
+
364
+ *lines* holds the header, the separator and the body rows. ``None`` means
365
+ the columns cannot fit *width*, and the caller keeps the source text.
366
+ """
367
+ header = [
368
+ f"{BOLD}{_markdown_inline(cell, bold=True)}{BOLD_OFF}"
369
+ for cell in _table_cells(lines[0])
370
+ ]
371
+ count = len(header)
372
+ body = []
373
+ for line in lines[2:]:
374
+ cells = [_markdown_inline(cell) for cell in _table_cells(line)]
375
+ body.append((cells + [""] * count)[:count])
376
+ available = width - 1 - 3 * count
377
+ if available < 3 * count:
378
+ return None
379
+ natural = [
380
+ max(1, *(_visible_width(row[index]) for row in [header, *body]))
381
+ for index in range(count)
382
+ ]
383
+ widths = natural
384
+ if sum(natural) > available:
385
+ low, high = 3, max(natural)
386
+ while low < high:
387
+ cap = (low + high + 1) // 2
388
+ if sum(min(value, cap) for value in natural) <= available:
389
+ low = cap
390
+ else:
391
+ high = cap - 1
392
+ widths = [min(value, low) for value in natural]
393
+ spare = available - sum(widths)
394
+ for index, value in enumerate(natural):
395
+ if spare and value > widths[index]:
396
+ widths[index] += 1
397
+ spare -= 1
398
+
399
+ def border(left: str, middle: str, right: str) -> str:
400
+ return left + middle.join("─" * (cell + 2) for cell in widths) + right
401
+
402
+ rendered = [border("┌", "┬", "┐")]
403
+ for row_index, row in enumerate([header, *body]):
404
+ if row_index:
405
+ rendered.append(border("├", "┼", "┤"))
406
+ fragments = [
407
+ _close_fragments(_wrap_styled(cell, widths[index], 0))
408
+ for index, cell in enumerate(row)
409
+ ]
410
+ for line_index in range(max(len(cell) for cell in fragments)):
411
+ parts = []
412
+ for index, cell in enumerate(fragments):
413
+ text = cell[line_index] if line_index < len(cell) else ""
414
+ padding = " " * (widths[index] - _visible_width(text))
415
+ parts.append(f" {text}{padding} ")
416
+ rendered.append("│" + "│".join(parts) + "│")
417
+ rendered.append(border("└", "┴", "┘"))
418
+ return rendered
239
419
 
240
420
 
241
421
  def _markdown_inline(line: str, *, bold: bool = False) -> str:
@@ -260,21 +440,39 @@ def _markdown_inline(line: str, *, bold: bool = False) -> str:
260
440
  return _MD_INLINE_RE.sub(replace, line)
261
441
 
262
442
 
263
- def _markdown_lines(lines: list[str]) -> list[str]:
443
+ def _markdown_lines(lines: list[str], width: int | None = None) -> list[str]:
264
444
  """Approximate Claude Code's markdown rendering for sanitized text lines.
265
445
 
266
446
  Styled lines end with a full reset: every span is already closed, and the
267
- reset keeps row wrappers from carrying an ever-growing SGR prefix.
447
+ reset keeps row wrappers from carrying an ever-growing SGR prefix. Tables
448
+ are drawn as boxes only when the target *width* is known.
268
449
  """
269
450
  rendered: list[str] = []
270
451
  in_fence = False
271
- for line in lines:
452
+ index = 0
453
+ while index < len(lines):
454
+ line = lines[index]
455
+ index += 1
272
456
  if _MD_FENCE_RE.match(line):
273
457
  in_fence = not in_fence
274
458
  continue
275
459
  if in_fence:
276
460
  rendered.append(line)
277
461
  continue
462
+ if (
463
+ width is not None
464
+ and line.lstrip().startswith("|")
465
+ and index < len(lines)
466
+ and _MD_TABLE_SEPARATOR_RE.match(lines[index])
467
+ ):
468
+ end = index + 1
469
+ while end < len(lines) and lines[end].lstrip().startswith("|"):
470
+ end += 1
471
+ table = _markdown_table(lines[index - 1:end], width)
472
+ if table is not None:
473
+ rendered.extend(table)
474
+ index = end
475
+ continue
278
476
  heading = _MD_HEADING_RE.match(line)
279
477
  if heading:
280
478
  inner = _markdown_inline(heading.group(2), bold=True)
@@ -285,23 +483,38 @@ def _markdown_lines(lines: list[str]) -> list[str]:
285
483
  return rendered
286
484
 
287
485
 
486
+ def _wrap_rows(rows: list[str], width: int | None, prefix: int) -> list[str]:
487
+ """Pre-wrap rendered rows when the pane *width* is known."""
488
+ if width is None:
489
+ return rows
490
+ return [
491
+ wrapped
492
+ for row in rows
493
+ for wrapped in _wrap_styled(row, width, _hang_for(row, prefix))
494
+ ]
495
+
496
+
288
497
  def _claude_native_block(
289
498
  marker: str, text: str, *, colour: str, markdown: bool = False,
499
+ width: int | None = None,
290
500
  ) -> str:
291
501
  """Render a static, non-interactive approximation of one Claude TUI block."""
292
502
  lines = _sanitize_text(text).strip().splitlines()
293
503
  if markdown:
294
- lines = _markdown_lines(lines)
504
+ lines = _markdown_lines(
505
+ lines, None if width is None else width - 2)
295
506
  if not lines:
296
507
  return ""
297
- continuation = "\n".join(f" {line}" for line in lines[1:])
298
- result = f"\n{colour}{marker}{RESET} {lines[0]}"
299
- if continuation:
300
- result += "\n" + continuation
301
- return result + "\n"
508
+ rows = [
509
+ f"{colour}{marker}{RESET} {lines[0]}",
510
+ *(f" {line}" for line in lines[1:]),
511
+ ]
512
+ return "\n" + "\n".join(_wrap_rows(rows, width, 2)) + "\n"
302
513
 
303
514
 
304
- def _render_claude_native_user(record: dict) -> str | None:
515
+ def _render_claude_native_user(
516
+ record: dict, width: int | None = None,
517
+ ) -> str | None:
305
518
  message = record.get("message")
306
519
  if not isinstance(message, dict):
307
520
  return None
@@ -316,11 +529,21 @@ def _render_claude_native_user(record: dict) -> str | None:
316
529
  ).strip()
317
530
  else:
318
531
  text = ""
319
- return _claude_native_block("❯", text, colour=CLAUDE_ACCENT) or None
532
+ if text.startswith("<task-notification>"):
533
+ # Claude shows a finished background task as one status line, not as
534
+ # a prompt carrying the notification's XML.
535
+ summary = _TASK_SUMMARY_RE.search(text)
536
+ if summary is None:
537
+ return None
538
+ return _claude_native_block(
539
+ "●", summary.group(1).strip(), colour=CLAUDE_MUTED, width=width,
540
+ ) or None
541
+ return _claude_native_block(
542
+ "❯", text, colour=CLAUDE_ACCENT, width=width) or None
320
543
 
321
544
 
322
545
  def _render_claude_native_assistant(
323
- record: dict, calls: dict[str, str],
546
+ record: dict, calls: dict[str, str], width: int | None = None,
324
547
  ):
325
548
  """Yield Claude-like read-only text/tool rows from semantic JSONL records."""
326
549
  message = record.get("message")
@@ -336,7 +559,7 @@ def _render_claude_native_assistant(
336
559
  if block_type == "text":
337
560
  rendered = _claude_native_block(
338
561
  "●", str(block.get("text", "")), colour=CLAUDE_ACCENT,
339
- markdown=True,
562
+ markdown=True, width=width,
340
563
  )
341
564
  if rendered:
342
565
  yield rendered
@@ -352,20 +575,19 @@ def _render_claude_native_assistant(
352
575
  if isinstance(tool_input, dict):
353
576
  for key in (
354
577
  "description", "command", "query", "pattern",
355
- "file_path", "path", "url",
578
+ "file_path", "path", "url", "skill",
356
579
  ):
357
580
  value = tool_input.get(key)
358
581
  if isinstance(value, str) and value.strip():
359
582
  summary = _sanitize_text(value.strip()).replace("\n", " ")
360
583
  break
361
584
  suffix = f"({_truncate(summary, 180)})" if summary else ""
362
- yield (
363
- f"\n{CLAUDE_ACCENT}●{RESET} {BOLD}{name}{RESET}{suffix}\n"
364
- )
585
+ row = f"{CLAUDE_ACCENT}●{RESET} {BOLD}{name}{RESET}{suffix}"
586
+ yield "\n" + "\n".join(_wrap_rows([row], width, 2)) + "\n"
365
587
 
366
588
 
367
589
  def _render_claude_native_tool_results(
368
- record: dict, calls: dict[str, str],
590
+ record: dict, calls: dict[str, str], width: int | None = None,
369
591
  ):
370
592
  message = record.get("message")
371
593
  if not isinstance(message, dict):
@@ -382,13 +604,16 @@ def _render_claude_native_tool_results(
382
604
  tool_id = block.get("tool_use_id")
383
605
  if isinstance(tool_id, str):
384
606
  calls.pop(tool_id, None)
385
- lines = _sanitize_text(_truncate(text, 500)).splitlines()
607
+ # _truncate sanitizes before appending its own styled ellipsis.
608
+ lines = _truncate(text, 500).splitlines()
386
609
  if not lines:
387
610
  continue
388
611
  colour = RED if block.get("is_error") is True else CLAUDE_MUTED
389
- yield f" {colour}⎿ {lines[0]}\n"
390
- for line in lines[1:]:
391
- yield f" {line}\n"
612
+ rows = [f" ⎿ {lines[0]}", *(f" {line}" for line in lines[1:])]
613
+ yield colour
614
+ for row in rows:
615
+ for wrapped in _wrap_rows([row], width, 5):
616
+ yield f"{wrapped}\n"
392
617
  yield RESET
393
618
 
394
619
 
@@ -435,36 +660,36 @@ def _claude_current_branch(records: list[dict]) -> list[dict]:
435
660
 
436
661
 
437
662
  def _render_claude_records(
438
- records: list[dict], *, claude_native: bool,
663
+ records: list[dict], *, claude_native: bool, width: int | None = None,
439
664
  ):
440
- """Render only the active Claude branch from buffered JSONL records."""
665
+ """Render only the active Claude branch from buffered JSONL records.
666
+
667
+ *width* pre-wraps native rows to the pane like Claude's own renderer.
668
+ """
441
669
  calls: dict[str, str] = {}
442
670
  for record in _claude_current_branch(records):
443
671
  rtype = record.get("type", "")
444
672
  if rtype in _SKIP_TYPES:
445
673
  continue
446
- if _is_real_user(record):
447
- rendered = (
448
- _render_claude_native_user(record)
449
- if claude_native
450
- else _render_user(record)
451
- )
674
+ if not claude_native:
675
+ if _is_real_user(record):
676
+ rendered = _render_user(record)
677
+ if rendered:
678
+ yield rendered
679
+ if rtype == "user":
680
+ yield from _render_claude_tool_results(record, calls)
681
+ elif rtype == "assistant":
682
+ yield from _render_assistant_blocks(record, calls)
683
+ continue
684
+ # Claude hides meta prompts such as a loaded skill's instructions.
685
+ if _is_real_user(record) and record.get("isMeta") is not True:
686
+ rendered = _render_claude_native_user(record, width)
452
687
  if rendered:
453
688
  yield rendered
454
689
  if rtype == "user":
455
- renderer = (
456
- _render_claude_native_tool_results
457
- if claude_native
458
- else _render_claude_tool_results
459
- )
460
- yield from renderer(record, calls)
690
+ yield from _render_claude_native_tool_results(record, calls, width)
461
691
  elif rtype == "assistant":
462
- renderer = (
463
- _render_claude_native_assistant
464
- if claude_native
465
- else _render_assistant_blocks
466
- )
467
- yield from renderer(record, calls)
692
+ yield from _render_claude_native_assistant(record, calls, width)
468
693
 
469
694
 
470
695
  def format_transcript(
@@ -472,6 +697,7 @@ def format_transcript(
472
697
  fmt: str | None = None,
473
698
  *,
474
699
  claude_native: bool = False,
700
+ width: int | None = None,
475
701
  ):
476
702
  """Read a session JSONL and yield ANSI-formatted strings.
477
703
 
@@ -482,6 +708,9 @@ def format_transcript(
482
708
  types actually present in the stream, so a bounded Codex rollout whose
483
709
  leading ``session_meta`` header was omitted still renders as Codex.
484
710
 
711
+ *width* (Claude native history only) wraps rows and draws tables for a
712
+ pane of that many cells.
713
+
485
714
  Callers should write each chunk to stdout, e.g.::
486
715
 
487
716
  for chunk in format_transcript(path_or_file):
@@ -538,7 +767,7 @@ def format_transcript(
538
767
  claude_records.append(record)
539
768
  if _codex is False:
540
769
  yield from _render_claude_records(
541
- claude_records, claude_native=claude_native)
770
+ claude_records, claude_native=claude_native, width=width)
542
771
  except OSError:
543
772
  yield f"{YELLOW}Could not read: {source}{RESET}\n"
544
773
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.2.dev5
3
+ Version: 0.4.2.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
@@ -1672,6 +1672,28 @@ def test_transcript_overlap_rejects_disagreeing_anchors():
1672
1672
  assert fast_display_server._transcript_rows_before_screen(rows, screen) == 3
1673
1673
 
1674
1674
 
1675
+ def test_transcript_overlap_ignores_screen_regions_rendered_differently():
1676
+ rows = (
1677
+ b"older message mentioning edited readme line",
1678
+ b"filler " * 20,
1679
+ b"truncated tool output",
1680
+ b"",
1681
+ b"newest message first distinctive row",
1682
+ b"newest message second distinctive row",
1683
+ )
1684
+ # A diff view tops the screen: one of its rows also occurs much earlier
1685
+ # in the transcript, disagreeing with the anchors in the newest message.
1686
+ screen = (
1687
+ b"older message mentioning edited readme line",
1688
+ b"+ added diff row only the screen shows",
1689
+ b"",
1690
+ b"newest message first distinctive row",
1691
+ b"newest message second distinctive row",
1692
+ )
1693
+
1694
+ assert fast_display_server._transcript_rows_before_screen(rows, screen) == 3
1695
+
1696
+
1675
1697
  @pytest.mark.parametrize(
1676
1698
  ("policy", "choice_required"),
1677
1699
  [("ask", True), ("native", False)],
@@ -1843,7 +1865,7 @@ def test_transcript_cache_evicts_least_recent_file_width(monkeypatch, tmp_path):
1843
1865
  fast_display_server._TRANSCRIPT_CACHE.clear()
1844
1866
 
1845
1867
 
1846
- def test_transcript_format_cache_reuses_exact_identity_across_widths(
1868
+ def test_transcript_format_cache_reuses_exact_identity_per_width(
1847
1869
  monkeypatch, tmp_path,
1848
1870
  ):
1849
1871
  pyte = fast_display_server._extended_pyte(__import__("pyte"))
@@ -1871,16 +1893,22 @@ def test_transcript_format_cache_reuses_exact_identity_across_widths(
1871
1893
  assert fast_display_server._transcript_rows(
1872
1894
  pyte, marker, 40, allow_stale=False
1873
1895
  ) is not None
1896
+ fast_display_server._TRANSCRIPT_CACHE.clear()
1874
1897
  assert fast_display_server._transcript_rows(
1875
- pyte, marker, 10, allow_stale=False
1898
+ pyte, marker, 40, allow_stale=False
1876
1899
  ) is not None
1877
1900
  assert len(calls) == 1
1901
+ # Claude history is wrapped for the pane, so a new width re-formats.
1902
+ assert fast_display_server._transcript_rows(
1903
+ pyte, marker, 10, allow_stale=False
1904
+ ) is not None
1905
+ assert len(calls) == 2
1878
1906
 
1879
1907
  path.write_text('{"type":"user","changed":true}\n')
1880
1908
  assert fast_display_server._transcript_rows(
1881
- pyte, marker, 20, allow_stale=False
1909
+ pyte, marker, 10, allow_stale=False
1882
1910
  ) is not None
1883
- assert len(calls) == 2
1911
+ assert len(calls) == 3
1884
1912
  finally:
1885
1913
  fast_display_server._TRANSCRIPT_CACHE.clear()
1886
1914
  fast_display_server._TRANSCRIPT_FORMAT_CACHE.clear()