railmux 0.4.2.dev5__tar.gz → 0.4.2.dev7__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.dev7}/PKG-INFO +1 -1
  2. railmux-0.4.2.dev7/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_server.py +42 -21
  4. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/transcript.py +303 -44
  5. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7/src/railmux.egg-info}/PKG-INFO +1 -1
  6. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_server.py +32 -4
  7. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_transcript.py +110 -0
  8. railmux-0.4.2.dev5/src/railmux/__init__.py +0 -1
  9. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/LICENSE +0 -0
  10. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/MANIFEST.in +0 -0
  11. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/README.md +0 -0
  12. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/pyproject.toml +0 -0
  13. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/setup.cfg +0 -0
  14. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/__main__.py +0 -0
  15. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/atomic_file.py +0 -0
  16. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/background_index.py +0 -0
  17. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/cli.py +0 -0
  18. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/codex_index.py +0 -0
  19. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/config.py +0 -0
  20. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/config_cli.py +0 -0
  21. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/diagnostic_contract.py +0 -0
  22. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/diagnostics.py +0 -0
  23. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/discovery.py +0 -0
  24. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/display_transport.py +0 -0
  25. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/entrypoint.py +0 -0
  26. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_client.py +0 -0
  27. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_history.py +0 -0
  28. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_input.py +0 -0
  29. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_protocol.py +0 -0
  30. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/favorites.py +0 -0
  31. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fuzzy.py +0 -0
  32. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/help_workspace.py +0 -0
  33. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/image_paste.py +0 -0
  34. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/launcher.py +0 -0
  35. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/legacy_sessions.py +0 -0
  36. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/local_clipboard.py +0 -0
  37. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/local_open.py +0 -0
  38. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/models.py +0 -0
  39. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/modes.py +0 -0
  40. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/mouse_manager.py +0 -0
  41. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/orphan_marker.py +0 -0
  42. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/pane_surface.py +0 -0
  43. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/path_codec.py +0 -0
  44. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/preview_pager.py +0 -0
  45. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/project_favorites.py +0 -0
  46. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/provider_paths.py +0 -0
  47. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/release_version.py +0 -0
  48. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/remote_config.py +0 -0
  49. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/renames.py +0 -0
  50. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/restart_state.py +0 -0
  51. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/runtime_config.py +0 -0
  52. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/scroll_agent.py +0 -0
  53. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/scroll_manager.py +0 -0
  54. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/selection_isolation.py +0 -0
  55. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/self_update.py +0 -0
  56. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_cache.py +0 -0
  57. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_index.py +0 -0
  58. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_lease.py +0 -0
  59. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/setting_contracts.py +0 -0
  60. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/settings.py +0 -0
  61. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_args.py +0 -0
  62. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_compat.py +0 -0
  63. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_display_diagnostics.py +0 -0
  64. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_doctor.py +0 -0
  65. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_preflight.py +0 -0
  66. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/system_deps.py +0 -0
  67. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/terminal_screen.py +0 -0
  68. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/terminal_status.py +0 -0
  69. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_binding_manager.py +0 -0
  70. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_capabilities.py +0 -0
  71. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_ctl.py +0 -0
  72. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_health.py +0 -0
  73. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_server.py +0 -0
  74. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tool_panes.py +0 -0
  75. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/__init__.py +0 -0
  76. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/_widgets.py +0 -0
  77. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/app.py +0 -0
  78. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/keymap.py +0 -0
  79. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/modals.py +0 -0
  80. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/projects_pane.py +0 -0
  81. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/running_pane.py +0 -0
  82. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/sessions_pane.py +0 -0
  83. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/sidebar.py +0 -0
  84. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/statusbar.py +0 -0
  85. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/workspace.py +0 -0
  86. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_attach_relay.py +0 -0
  87. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_bootstrap.py +0 -0
  88. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_install_log.py +0 -0
  89. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_msys2.py +0 -0
  90. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_pacman.py +0 -0
  91. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_paths.py +0 -0
  92. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_tmux_lifecycle.py +0 -0
  93. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_ui_transition.py +0 -0
  94. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/SOURCES.txt +0 -0
  95. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/dependency_links.txt +0 -0
  96. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/entry_points.txt +0 -0
  97. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/requires.txt +0 -0
  98. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/top_level.txt +0 -0
  99. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/app_test_harness.py +0 -0
  100. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_agent_workspace.py +0 -0
  101. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_codex_placeholder.py +0 -0
  102. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_quit.py +0 -0
  103. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_restart_restore.py +0 -0
  104. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_running_recovery.py +0 -0
  105. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_atomic_file.py +0 -0
  106. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_background_index.py +0 -0
  107. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_claude_continuation.py +0 -0
  108. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_cli.py +0 -0
  109. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_code_map.py +0 -0
  110. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_delete.py +0 -0
  111. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_index.py +0 -0
  112. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_lineage.py +0 -0
  113. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_transcript.py +0 -0
  114. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_config.py +0 -0
  115. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_config_cli.py +0 -0
  116. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_denested_transport_benchmark.py +0 -0
  117. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_diagnostics.py +0 -0
  118. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_discovery.py +0 -0
  119. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_display_transport.py +0 -0
  120. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_bootstrap.py +0 -0
  121. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_history.py +0 -0
  122. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_protocol.py +0 -0
  123. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_selection.py +0 -0
  124. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_terminal.py +0 -0
  125. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_focus_highlight.py +0 -0
  126. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fullscreen_binding.py +0 -0
  127. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fuzzy.py +0 -0
  128. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_help_agent.py +0 -0
  129. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_help_workspace.py +0 -0
  130. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_history_cleanup.py +0 -0
  131. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_image_paste.py +0 -0
  132. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_keymap.py +0 -0
  133. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_launcher.py +0 -0
  134. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_layout_profile.py +0 -0
  135. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_legacy_sessions.py +0 -0
  136. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_local_clipboard.py +0 -0
  137. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_local_open.py +0 -0
  138. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_modals.py +0 -0
  139. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_mode_selection.py +0 -0
  140. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_modes.py +0 -0
  141. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_mouse_manager.py +0 -0
  142. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_new_project.py +0 -0
  143. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_options.py +0 -0
  144. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_orphan_recovery.py +0 -0
  145. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_pane_surface.py +0 -0
  146. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_path_codec.py +0 -0
  147. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_preview_pager.py +0 -0
  148. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_project_favorites.py +0 -0
  149. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_projects_pane.py +0 -0
  150. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_provider_paths.py +0 -0
  151. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_notes.py +0 -0
  152. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_version.py +0 -0
  153. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_workflow.py +0 -0
  154. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_remote_config.py +0 -0
  155. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_renames.py +0 -0
  156. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_restart_state.py +0 -0
  157. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_running_pane.py +0 -0
  158. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_running_sort.py +0 -0
  159. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_runtime_config.py +0 -0
  160. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_scroll_agent.py +0 -0
  161. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_scroll_manager.py +0 -0
  162. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_selection_isolation.py +0 -0
  163. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_self_update.py +0 -0
  164. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_cache.py +0 -0
  165. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_index.py +0 -0
  166. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_lease.py +0 -0
  167. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_sessions_pane.py +0 -0
  168. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_setting_contracts.py +0 -0
  169. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_settings.py +0 -0
  170. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_sidebar.py +0 -0
  171. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_args.py +0 -0
  172. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_compat.py +0 -0
  173. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_display_benchmark.py +0 -0
  174. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_display_diagnostics.py +0 -0
  175. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_doctor.py +0 -0
  176. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_status.py +0 -0
  177. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_statusbar.py +0 -0
  178. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_system_deps.py +0 -0
  179. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_terminal_size.py +0 -0
  180. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_terminal_status.py +0 -0
  181. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_termux_local_touch.py +0 -0
  182. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_binding_manager.py +0 -0
  183. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_capabilities.py +0 -0
  184. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_ctl.py +0 -0
  185. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_health.py +0 -0
  186. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_integration.py +0 -0
  187. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_server.py +0 -0
  188. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_status.py +0 -0
  189. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tool_panes.py +0 -0
  190. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_utf8_state.py +0 -0
  191. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_web_demo_recorder.py +0 -0
  192. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_wheel_smoke.py +0 -0
  193. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_widgets.py +0 -0
  194. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_attach_relay.py +0 -0
  195. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_bootstrap.py +0 -0
  196. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_install_log.py +0 -0
  197. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_msys2.py +0 -0
  198. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_pacman.py +0 -0
  199. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_paths.py +0 -0
  200. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_synchronized_screen.py +0 -0
  201. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_tmux_lifecycle.py +0 -0
  202. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_ui_transition.py +0 -0
  203. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_wrapper_contract.py +0 -0
  204. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_yolo_prompt.py +0 -0
  205. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/denested_transport_benchmark.py +0 -0
  206. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/release_notes.py +0 -0
  207. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/wheel_smoke.py +0 -0
  208. {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/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.dev7
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.dev7"
@@ -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)
@@ -235,15 +235,220 @@ _MD_INLINE_RE = re.compile(
235
235
  r"|\*\*(?=\S)(.+?)(?<=\S)\*\*"
236
236
  r"|__(?=\S)(.+?)(?<=\S)__"
237
237
  r"|(?<![\w*])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![\w*])"
238
+ r"|\[([^\]\n]+)\]\(([^)\s]+)\)"
238
239
  )
240
+ _MD_TABLE_SEPARATOR_RE = re.compile(
241
+ r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
242
+ _MD_TABLE_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
243
+ _LIST_HANG_RE = re.compile(r" *(?:(?:[-*+]|\d+[.)]) +)?")
244
+ _SGR_RE = re.compile(r"\x1b\[[0-9;]*m")
245
+ _WRAP_TOKEN_RE = re.compile(r"\x1b\[[0-9;]*m| +|[^ \x1b]+")
246
+ _TASK_SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.DOTALL)
247
+
248
+ _char_width_function = None
249
+
250
+
251
+ def _char_width(char: str) -> int:
252
+ """Terminal cell width of *char*, matching pyte's ``wcwidth`` when present."""
253
+ global _char_width_function
254
+ if _char_width_function is None:
255
+ try:
256
+ from wcwidth import wcwidth as _char_width_function
257
+ except ImportError:
258
+ import unicodedata
259
+
260
+ def _char_width_function(value: str) -> int:
261
+ if unicodedata.combining(value):
262
+ return 0
263
+ return 2 if unicodedata.east_asian_width(value) in "WF" else 1
264
+ return max(0, _char_width_function(char))
265
+
266
+
267
+ def _visible_width(text: str) -> int:
268
+ # Claude pads an emoji-presentation selector (as in "⚠️") to two cells.
269
+ return sum(
270
+ 1 if char == "\ufe0f" else _char_width(char)
271
+ for char in _SGR_RE.sub("", text)
272
+ )
273
+
274
+
275
+ def _wrap_styled(line: str, width: int, hang: int) -> list[str]:
276
+ """Word-wrap one styled line the way Claude's Ink renderer does.
277
+
278
+ Rows break at spaces; only a word wider than a whole row is split. Rows
279
+ after the first are indented by *hang* columns so list items and message
280
+ bodies keep their hanging indent instead of restarting at column zero.
281
+ """
282
+ line = line.expandtabs(8)
283
+ if width <= 0 or _visible_width(line) <= width:
284
+ return [line]
285
+ if hang >= width // 2:
286
+ hang = 0
287
+ rows: list[str] = []
288
+ row: list[str] = []
289
+ column = 0
290
+
291
+ def newline() -> None:
292
+ nonlocal row, column
293
+ rows.append("".join(row).rstrip(" "))
294
+ row = [" " * hang]
295
+ column = hang
296
+
297
+ for token in _WRAP_TOKEN_RE.findall(line):
298
+ if token.startswith("\x1b"):
299
+ row.append(token)
300
+ continue
301
+ token_width = _visible_width(token)
302
+ if token.startswith(" "):
303
+ if column + token_width <= width:
304
+ row.append(token)
305
+ column += token_width
306
+ else:
307
+ newline()
308
+ continue
309
+ capacity = width - hang
310
+ if column + token_width > width and column > hang:
311
+ # Like wrap-ansi: start a word on a fresh row when that fits it,
312
+ # or when splitting it from there needs fewer breaks.
313
+ breaks_here = 1 + (token_width - (width - column) - 1) // capacity
314
+ if (
315
+ token_width <= capacity
316
+ or (token_width - 1) // capacity < breaks_here
317
+ ):
318
+ newline()
319
+ for char in token:
320
+ char_width = _char_width(char)
321
+ if column + char_width > width and column > hang:
322
+ newline()
323
+ row.append(char)
324
+ column += char_width
325
+ rows.append("".join(row).rstrip(" "))
326
+ return rows
327
+
328
+
329
+ def _hang_for(row: str, prefix: int) -> int:
330
+ """Hanging indent for a row whose own content starts after *prefix* cells."""
331
+ content = _SGR_RE.sub("", row)[prefix:]
332
+ return prefix + len(_LIST_HANG_RE.match(content).group(0))
333
+
334
+
335
+ def _close_fragments(fragments: list[str]) -> list[str]:
336
+ """Make each wrapped fragment carry and close its own SGR state.
337
+
338
+ Table cells sit beside other cells on one row, so a style that continues
339
+ onto a cell's next fragment must not leak across the borders between.
340
+ """
341
+ closed: list[str] = []
342
+ active: list[str] = []
343
+ for fragment in fragments:
344
+ prefix = "".join(active)
345
+ for code in _SGR_RE.findall(fragment):
346
+ if code == RESET:
347
+ active.clear()
348
+ else:
349
+ active.append(code)
350
+ closed.append(f"{prefix}{fragment}{RESET}")
351
+ return closed
352
+
353
+
354
+ def _table_cells(line: str) -> list[str]:
355
+ stripped = line.strip()
356
+ if stripped.startswith("|"):
357
+ stripped = stripped[1:]
358
+ if stripped.endswith("|") and not stripped.endswith("\\|"):
359
+ stripped = stripped[:-1]
360
+ return [
361
+ cell.strip().replace("\\|", "|")
362
+ for cell in _MD_TABLE_CELL_SPLIT_RE.split(stripped)
363
+ ]
364
+
365
+
366
+ def _markdown_table(lines: list[str], width: int) -> list[str] | None:
367
+ """Draw a markdown table as Claude does: a box whose cells wrap to fit.
368
+
369
+ *lines* holds the header, the separator and the body rows. ``None`` means
370
+ the columns cannot fit *width*, and the caller keeps the source text.
371
+ """
372
+ header = [
373
+ f"{BOLD}{_markdown_inline(cell, bold=True)}{BOLD_OFF}"
374
+ for cell in _table_cells(lines[0])
375
+ ]
376
+ count = len(header)
377
+ body = []
378
+ for line in lines[2:]:
379
+ cells = [_markdown_inline(cell) for cell in _table_cells(line)]
380
+ body.append((cells + [""] * count)[:count])
381
+ # Claude keeps two spare cells beside the borders and padding.
382
+ available = width - 3 - 3 * count
383
+ if available < 3 * count:
384
+ return None
385
+ rows = [header, *body]
386
+ natural = [
387
+ max(1, *(_visible_width(row[index]) for row in rows))
388
+ for index in range(count)
389
+ ]
390
+ longest_word = [
391
+ max(
392
+ _visible_width(word)
393
+ for row in rows
394
+ for word in [*_SGR_RE.sub("", row[index]).split(" "), ""]
395
+ )
396
+ for index in range(count)
397
+ ]
398
+ minimum = [max(1, min(a, b)) for a, b in zip(natural, longest_word)]
399
+ widths = natural
400
+ if sum(natural) > available and sum(minimum) <= available:
401
+ # Claude's split: every column fits its longest word, and the spare
402
+ # cells go to each column in proportion to how much more it wants.
403
+ spare = available - sum(minimum)
404
+ wanted = sum(n - m for n, m in zip(natural, minimum))
405
+ widths = [
406
+ m + spare * (n - m) // wanted for n, m in zip(natural, minimum)
407
+ ]
408
+ elif sum(natural) > available:
409
+ low, high = 3, max(natural)
410
+ while low < high:
411
+ cap = (low + high + 1) // 2
412
+ if sum(min(value, cap) for value in natural) <= available:
413
+ low = cap
414
+ else:
415
+ high = cap - 1
416
+ widths = [min(value, low) for value in natural]
417
+ spare = available - sum(widths)
418
+ for index, value in enumerate(natural):
419
+ if spare and value > widths[index]:
420
+ widths[index] += 1
421
+ spare -= 1
422
+
423
+ def border(left: str, middle: str, right: str) -> str:
424
+ return left + middle.join("─" * (cell + 2) for cell in widths) + right
425
+
426
+ rendered = [border("┌", "┬", "┐")]
427
+ for row_index, row in enumerate([header, *body]):
428
+ if row_index:
429
+ rendered.append(border("├", "┼", "┤"))
430
+ fragments = [
431
+ _close_fragments(_wrap_styled(cell, widths[index], 0))
432
+ for index, cell in enumerate(row)
433
+ ]
434
+ for line_index in range(max(len(cell) for cell in fragments)):
435
+ parts = []
436
+ for index, cell in enumerate(fragments):
437
+ text = cell[line_index] if line_index < len(cell) else ""
438
+ padding = " " * (widths[index] - _visible_width(text))
439
+ parts.append(f" {text}{padding} ")
440
+ rendered.append("│" + "│".join(parts) + "│")
441
+ rendered.append(border("└", "┴", "┘"))
442
+ return rendered
239
443
 
240
444
 
241
445
  def _markdown_inline(line: str, *, bold: bool = False) -> str:
242
- """Style inline code, bold and italic spans like Claude's markdown view.
446
+ """Style inline code, bold, italic and link spans like Claude's view.
243
447
 
244
448
  Only targeted "off" codes close each span, so an enclosing style (for
245
449
  example a bold heading) survives an inner code or italic span. *bold*
246
450
  marks an already-bold context whose nested strong spans must not end it.
451
+ A link shows only its text, as Claude's clickable link does.
247
452
  """
248
453
 
249
454
  def replace(match: re.Match[str]) -> str:
@@ -251,6 +456,8 @@ def _markdown_inline(line: str, *, bold: bool = False) -> str:
251
456
  match.group(2), match.group(3), match.group(4), match.group(5))
252
457
  if code is not None:
253
458
  return f"{CLAUDE_ACCENT}{code}{FG_DEFAULT}"
459
+ if match.group(6) is not None:
460
+ return _markdown_inline(match.group(6), bold=bold)
254
461
  if strong is not None or strong_alt is not None:
255
462
  inner = _markdown_inline(
256
463
  strong if strong is not None else strong_alt, bold=True)
@@ -260,48 +467,84 @@ def _markdown_inline(line: str, *, bold: bool = False) -> str:
260
467
  return _MD_INLINE_RE.sub(replace, line)
261
468
 
262
469
 
263
- def _markdown_lines(lines: list[str]) -> list[str]:
470
+ def _markdown_lines(lines: list[str], width: int | None = None) -> list[str]:
264
471
  """Approximate Claude Code's markdown rendering for sanitized text lines.
265
472
 
266
473
  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.
474
+ reset keeps row wrappers from carrying an ever-growing SGR prefix. Tables
475
+ are drawn as boxes only when the target *width* is known.
268
476
  """
269
477
  rendered: list[str] = []
270
478
  in_fence = False
271
- for line in lines:
479
+ index = 0
480
+ while index < len(lines):
481
+ line = lines[index]
482
+ index += 1
272
483
  if _MD_FENCE_RE.match(line):
273
484
  in_fence = not in_fence
274
485
  continue
275
486
  if in_fence:
276
487
  rendered.append(line)
277
488
  continue
489
+ if (
490
+ width is not None
491
+ and line.lstrip().startswith("|")
492
+ and index < len(lines)
493
+ and _MD_TABLE_SEPARATOR_RE.match(lines[index])
494
+ ):
495
+ end = index + 1
496
+ while end < len(lines) and lines[end].lstrip().startswith("|"):
497
+ end += 1
498
+ table = _markdown_table(lines[index - 1:end], width)
499
+ if table is not None:
500
+ rendered.extend(table)
501
+ index = end
502
+ continue
278
503
  heading = _MD_HEADING_RE.match(line)
279
504
  if heading:
280
505
  inner = _markdown_inline(heading.group(2), bold=True)
281
506
  rendered.append(f"{BOLD}{inner}{RESET}")
507
+ # Claude always leaves a blank line below a heading.
508
+ if index < len(lines) and lines[index].strip():
509
+ rendered.append("")
282
510
  continue
283
511
  styled = _markdown_inline(line)
284
512
  rendered.append(styled + RESET if styled != line else line)
285
513
  return rendered
286
514
 
287
515
 
516
+ def _wrap_rows(rows: list[str], width: int | None, prefix: int) -> list[str]:
517
+ """Pre-wrap rendered rows when the pane *width* is known."""
518
+ if width is None:
519
+ return rows
520
+ return [
521
+ wrapped
522
+ for row in rows
523
+ for wrapped in _wrap_styled(row, width, _hang_for(row, prefix))
524
+ ]
525
+
526
+
288
527
  def _claude_native_block(
289
528
  marker: str, text: str, *, colour: str, markdown: bool = False,
529
+ width: int | None = None,
290
530
  ) -> str:
291
531
  """Render a static, non-interactive approximation of one Claude TUI block."""
292
532
  lines = _sanitize_text(text).strip().splitlines()
293
533
  if markdown:
294
- lines = _markdown_lines(lines)
534
+ lines = _markdown_lines(
535
+ lines, None if width is None else width - 2)
295
536
  if not lines:
296
537
  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"
538
+ rows = [
539
+ f"{colour}{marker}{RESET} {lines[0]}",
540
+ *(f" {line}" for line in lines[1:]),
541
+ ]
542
+ return "\n" + "\n".join(_wrap_rows(rows, width, 2)) + "\n"
302
543
 
303
544
 
304
- def _render_claude_native_user(record: dict) -> str | None:
545
+ def _render_claude_native_user(
546
+ record: dict, width: int | None = None,
547
+ ) -> str | None:
305
548
  message = record.get("message")
306
549
  if not isinstance(message, dict):
307
550
  return None
@@ -316,11 +559,21 @@ def _render_claude_native_user(record: dict) -> str | None:
316
559
  ).strip()
317
560
  else:
318
561
  text = ""
319
- return _claude_native_block("❯", text, colour=CLAUDE_ACCENT) or None
562
+ if text.startswith("<task-notification>"):
563
+ # Claude shows a finished background task as one status line, not as
564
+ # a prompt carrying the notification's XML.
565
+ summary = _TASK_SUMMARY_RE.search(text)
566
+ if summary is None:
567
+ return None
568
+ return _claude_native_block(
569
+ "●", summary.group(1).strip(), colour=CLAUDE_MUTED, width=width,
570
+ ) or None
571
+ return _claude_native_block(
572
+ "❯", text, colour=CLAUDE_ACCENT, width=width) or None
320
573
 
321
574
 
322
575
  def _render_claude_native_assistant(
323
- record: dict, calls: dict[str, str],
576
+ record: dict, calls: dict[str, str], width: int | None = None,
324
577
  ):
325
578
  """Yield Claude-like read-only text/tool rows from semantic JSONL records."""
326
579
  message = record.get("message")
@@ -336,7 +589,7 @@ def _render_claude_native_assistant(
336
589
  if block_type == "text":
337
590
  rendered = _claude_native_block(
338
591
  "●", str(block.get("text", "")), colour=CLAUDE_ACCENT,
339
- markdown=True,
592
+ markdown=True, width=width,
340
593
  )
341
594
  if rendered:
342
595
  yield rendered
@@ -352,20 +605,19 @@ def _render_claude_native_assistant(
352
605
  if isinstance(tool_input, dict):
353
606
  for key in (
354
607
  "description", "command", "query", "pattern",
355
- "file_path", "path", "url",
608
+ "file_path", "path", "url", "skill",
356
609
  ):
357
610
  value = tool_input.get(key)
358
611
  if isinstance(value, str) and value.strip():
359
612
  summary = _sanitize_text(value.strip()).replace("\n", " ")
360
613
  break
361
614
  suffix = f"({_truncate(summary, 180)})" if summary else ""
362
- yield (
363
- f"\n{CLAUDE_ACCENT}●{RESET} {BOLD}{name}{RESET}{suffix}\n"
364
- )
615
+ row = f"{CLAUDE_ACCENT}●{RESET} {BOLD}{name}{RESET}{suffix}"
616
+ yield "\n" + "\n".join(_wrap_rows([row], width, 2)) + "\n"
365
617
 
366
618
 
367
619
  def _render_claude_native_tool_results(
368
- record: dict, calls: dict[str, str],
620
+ record: dict, calls: dict[str, str], width: int | None = None,
369
621
  ):
370
622
  message = record.get("message")
371
623
  if not isinstance(message, dict):
@@ -382,13 +634,16 @@ def _render_claude_native_tool_results(
382
634
  tool_id = block.get("tool_use_id")
383
635
  if isinstance(tool_id, str):
384
636
  calls.pop(tool_id, None)
385
- lines = _sanitize_text(_truncate(text, 500)).splitlines()
637
+ # _truncate sanitizes before appending its own styled ellipsis.
638
+ lines = _truncate(text, 500).splitlines()
386
639
  if not lines:
387
640
  continue
388
641
  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"
642
+ rows = [f" ⎿ {lines[0]}", *(f" {line}" for line in lines[1:])]
643
+ yield colour
644
+ for row in rows:
645
+ for wrapped in _wrap_rows([row], width, 5):
646
+ yield f"{wrapped}\n"
392
647
  yield RESET
393
648
 
394
649
 
@@ -435,36 +690,36 @@ def _claude_current_branch(records: list[dict]) -> list[dict]:
435
690
 
436
691
 
437
692
  def _render_claude_records(
438
- records: list[dict], *, claude_native: bool,
693
+ records: list[dict], *, claude_native: bool, width: int | None = None,
439
694
  ):
440
- """Render only the active Claude branch from buffered JSONL records."""
695
+ """Render only the active Claude branch from buffered JSONL records.
696
+
697
+ *width* pre-wraps native rows to the pane like Claude's own renderer.
698
+ """
441
699
  calls: dict[str, str] = {}
442
700
  for record in _claude_current_branch(records):
443
701
  rtype = record.get("type", "")
444
702
  if rtype in _SKIP_TYPES:
445
703
  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
- )
704
+ if not claude_native:
705
+ if _is_real_user(record):
706
+ rendered = _render_user(record)
707
+ if rendered:
708
+ yield rendered
709
+ if rtype == "user":
710
+ yield from _render_claude_tool_results(record, calls)
711
+ elif rtype == "assistant":
712
+ yield from _render_assistant_blocks(record, calls)
713
+ continue
714
+ # Claude hides meta prompts such as a loaded skill's instructions.
715
+ if _is_real_user(record) and record.get("isMeta") is not True:
716
+ rendered = _render_claude_native_user(record, width)
452
717
  if rendered:
453
718
  yield rendered
454
719
  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)
720
+ yield from _render_claude_native_tool_results(record, calls, width)
461
721
  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)
722
+ yield from _render_claude_native_assistant(record, calls, width)
468
723
 
469
724
 
470
725
  def format_transcript(
@@ -472,6 +727,7 @@ def format_transcript(
472
727
  fmt: str | None = None,
473
728
  *,
474
729
  claude_native: bool = False,
730
+ width: int | None = None,
475
731
  ):
476
732
  """Read a session JSONL and yield ANSI-formatted strings.
477
733
 
@@ -482,6 +738,9 @@ def format_transcript(
482
738
  types actually present in the stream, so a bounded Codex rollout whose
483
739
  leading ``session_meta`` header was omitted still renders as Codex.
484
740
 
741
+ *width* (Claude native history only) wraps rows and draws tables for a
742
+ pane of that many cells.
743
+
485
744
  Callers should write each chunk to stdout, e.g.::
486
745
 
487
746
  for chunk in format_transcript(path_or_file):
@@ -538,7 +797,7 @@ def format_transcript(
538
797
  claude_records.append(record)
539
798
  if _codex is False:
540
799
  yield from _render_claude_records(
541
- claude_records, claude_native=claude_native)
800
+ claude_records, claude_native=claude_native, width=width)
542
801
  except OSError:
543
802
  yield f"{YELLOW}Could not read: {source}{RESET}\n"
544
803
  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.dev7
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()