railmux 0.4.0.dev32__tar.gz → 0.4.0.dev33__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 (183) hide show
  1. {railmux-0.4.0.dev32/src/railmux.egg-info → railmux-0.4.0.dev33}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev33/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/transcript.py +46 -8
  4. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/app.py +69 -31
  5. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33/src/railmux.egg-info}/PKG-INFO +1 -1
  6. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_delete.py +6 -3
  7. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_focus_highlight.py +120 -3
  8. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_integration.py +66 -0
  9. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_transcript.py +38 -0
  10. railmux-0.4.0.dev32/src/railmux/__init__.py +0 -1
  11. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/LICENSE +0 -0
  12. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/MANIFEST.in +0 -0
  13. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/README.md +0 -0
  14. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/pyproject.toml +0 -0
  15. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/setup.cfg +0 -0
  16. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/__main__.py +0 -0
  17. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/atomic_file.py +0 -0
  18. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/background_index.py +0 -0
  19. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/cli.py +0 -0
  20. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/codex_index.py +0 -0
  21. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/config.py +0 -0
  22. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/config_cli.py +0 -0
  23. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/diagnostics.py +0 -0
  24. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/discovery.py +0 -0
  25. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/display_transport.py +0 -0
  26. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/entrypoint.py +0 -0
  27. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_client.py +0 -0
  28. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_history.py +0 -0
  29. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_input.py +0 -0
  30. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_protocol.py +0 -0
  31. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_server.py +0 -0
  32. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/favorites.py +0 -0
  33. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fuzzy.py +0 -0
  34. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/help_workspace.py +0 -0
  35. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/launcher.py +0 -0
  36. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/legacy_sessions.py +0 -0
  37. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/local_clipboard.py +0 -0
  38. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/local_open.py +0 -0
  39. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/models.py +0 -0
  40. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/modes.py +0 -0
  41. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/mouse_manager.py +0 -0
  42. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/orphan_marker.py +0 -0
  43. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/pane_surface.py +0 -0
  44. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/path_codec.py +0 -0
  45. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/provider_paths.py +0 -0
  46. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/remote_config.py +0 -0
  47. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/renames.py +0 -0
  48. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/restart_state.py +0 -0
  49. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/runtime_config.py +0 -0
  50. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/scroll_agent.py +0 -0
  51. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/scroll_manager.py +0 -0
  52. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/selection_isolation.py +0 -0
  53. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/self_update.py +0 -0
  54. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_cache.py +0 -0
  55. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_index.py +0 -0
  56. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_lease.py +0 -0
  57. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/setting_contracts.py +0 -0
  58. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/settings.py +0 -0
  59. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_args.py +0 -0
  60. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_compat.py +0 -0
  61. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_display_diagnostics.py +0 -0
  62. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_doctor.py +0 -0
  63. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/system_deps.py +0 -0
  64. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/terminal_status.py +0 -0
  65. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_binding_manager.py +0 -0
  66. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_ctl.py +0 -0
  67. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_health.py +0 -0
  68. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_server.py +0 -0
  69. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tool_panes.py +0 -0
  70. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/__init__.py +0 -0
  71. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/_widgets.py +0 -0
  72. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/keymap.py +0 -0
  73. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/modals.py +0 -0
  74. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/projects_pane.py +0 -0
  75. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/running_pane.py +0 -0
  76. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/sessions_pane.py +0 -0
  77. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/sidebar.py +0 -0
  78. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/statusbar.py +0 -0
  79. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/workspace.py +0 -0
  80. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_attach_relay.py +0 -0
  81. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_bootstrap.py +0 -0
  82. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_install_log.py +0 -0
  83. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_msys2.py +0 -0
  84. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_pacman.py +0 -0
  85. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_paths.py +0 -0
  86. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_tmux_lifecycle.py +0 -0
  87. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_ui_transition.py +0 -0
  88. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/SOURCES.txt +0 -0
  89. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/dependency_links.txt +0 -0
  90. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/entry_points.txt +0 -0
  91. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/requires.txt +0 -0
  92. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/top_level.txt +0 -0
  93. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_agent_workspace.py +0 -0
  94. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_atomic_file.py +0 -0
  95. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_background_index.py +0 -0
  96. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_cli.py +0 -0
  97. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_index.py +0 -0
  98. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_lineage.py +0 -0
  99. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_transcript.py +0 -0
  100. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_config.py +0 -0
  101. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_config_cli.py +0 -0
  102. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_denested_transport_benchmark.py +0 -0
  103. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_diagnostics.py +0 -0
  104. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_discovery.py +0 -0
  105. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_display_transport.py +0 -0
  106. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fast_full_window.py +0 -0
  107. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fullscreen_binding.py +0 -0
  108. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fuzzy.py +0 -0
  109. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_help_agent.py +0 -0
  110. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_help_workspace.py +0 -0
  111. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_history_cleanup.py +0 -0
  112. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_keymap.py +0 -0
  113. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_launcher.py +0 -0
  114. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_layout_profile.py +0 -0
  115. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_legacy_sessions.py +0 -0
  116. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_local_clipboard.py +0 -0
  117. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_local_open.py +0 -0
  118. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_modals.py +0 -0
  119. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_mode_selection.py +0 -0
  120. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_modes.py +0 -0
  121. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_mouse_manager.py +0 -0
  122. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_new_project.py +0 -0
  123. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_options.py +0 -0
  124. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_orphan_recovery.py +0 -0
  125. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_pane_surface.py +0 -0
  126. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_path_codec.py +0 -0
  127. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_projects_pane.py +0 -0
  128. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_provider_paths.py +0 -0
  129. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_release_notes.py +0 -0
  130. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_release_workflow.py +0 -0
  131. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_remote_config.py +0 -0
  132. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_renames.py +0 -0
  133. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_restart_state.py +0 -0
  134. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_running_pane.py +0 -0
  135. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_running_sort.py +0 -0
  136. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_runtime_config.py +0 -0
  137. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_scroll_agent.py +0 -0
  138. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_scroll_manager.py +0 -0
  139. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_selection_isolation.py +0 -0
  140. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_self_update.py +0 -0
  141. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_cache.py +0 -0
  142. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_index.py +0 -0
  143. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_lease.py +0 -0
  144. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_sessions_pane.py +0 -0
  145. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_setting_contracts.py +0 -0
  146. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_settings.py +0 -0
  147. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_sidebar.py +0 -0
  148. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_soft_quit.py +0 -0
  149. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_args.py +0 -0
  150. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_compat.py +0 -0
  151. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_display_benchmark.py +0 -0
  152. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_display_diagnostics.py +0 -0
  153. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_doctor.py +0 -0
  154. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_status.py +0 -0
  155. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_statusbar.py +0 -0
  156. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_system_deps.py +0 -0
  157. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_terminal_size.py +0 -0
  158. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_terminal_status.py +0 -0
  159. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_termux_local_touch.py +0 -0
  160. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_binding_manager.py +0 -0
  161. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_ctl.py +0 -0
  162. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_health.py +0 -0
  163. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_server.py +0 -0
  164. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_status.py +0 -0
  165. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tool_panes.py +0 -0
  166. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_utf8_state.py +0 -0
  167. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_web_demo_recorder.py +0 -0
  168. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_wheel_smoke.py +0 -0
  169. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_widgets.py +0 -0
  170. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_attach_relay.py +0 -0
  171. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_bootstrap.py +0 -0
  172. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_install_log.py +0 -0
  173. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_msys2.py +0 -0
  174. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_pacman.py +0 -0
  175. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_paths.py +0 -0
  176. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_tmux_lifecycle.py +0 -0
  177. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_ui_transition.py +0 -0
  178. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_wrapper_contract.py +0 -0
  179. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_yolo_prompt.py +0 -0
  180. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/denested_transport_benchmark.py +0 -0
  181. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/release_notes.py +0 -0
  182. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/wheel_smoke.py +0 -0
  183. {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/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.0.dev32
3
+ Version: 0.4.0.dev33
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.0.dev33"
@@ -7,6 +7,7 @@ Usage::
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import io
10
11
  import json
11
12
  import os
12
13
  import sys
@@ -41,17 +42,46 @@ _SKIP_TYPES = frozenset({
41
42
  })
42
43
 
43
44
  # Top-level record types that only ever appear in a Codex rollout JSONL. The
44
- # preview pipeline in the UI runs ``tail -n 2000`` before piping into this
45
- # module, so for rollouts longer than 2000 lines the leading ``session_meta``
46
- # header is dropped and the first record seen here is a ``response_item`` /
47
- # ``event_msg`` / ``turn_context``. Detect Codex by the *presence* of any of
48
- # these types rather than by a leading ``session_meta`` (see issue #5).
45
+ # preview reader keeps only the final 2,000 records, so for longer rollouts the
46
+ # leading ``session_meta`` header is dropped and the first record seen here is
47
+ # a ``response_item`` / ``event_msg`` / ``turn_context``. Detect Codex by the
48
+ # *presence* of any of these types rather than by a leading ``session_meta``
49
+ # (see issue #5).
49
50
  _CODEX_TYPES = frozenset({
50
51
  "session_meta", "response_item", "event_msg", "turn_context",
51
52
  "compacted", "world_state",
52
53
  })
53
54
  _CLAUDE_TYPES = _SKIP_TYPES | frozenset({"user", "assistant"})
54
55
 
56
+ _TAIL_BLOCK_SIZE = 64 * 1024
57
+
58
+
59
+ def _tail_utf8_lines(path: Path, limit: int) -> io.StringIO:
60
+ """Return the final *limit* UTF-8 lines without scanning from the start.
61
+
62
+ The preview caller previously launched a separate ``tail`` process before
63
+ the Python renderer. Reading backwards here removes that process from the
64
+ latency-sensitive Windows path while retaining the same record boundary.
65
+ One extra newline is collected before slicing so the first returned line
66
+ can never be a block-boundary fragment.
67
+ """
68
+ chunks: list[bytes] = []
69
+ newline_count = 0
70
+ with path.open("rb") as stream:
71
+ stream.seek(0, os.SEEK_END)
72
+ position = stream.tell()
73
+ while position > 0 and newline_count <= limit:
74
+ size = min(_TAIL_BLOCK_SIZE, position)
75
+ position -= size
76
+ stream.seek(position)
77
+ chunk = stream.read(size)
78
+ chunks.append(chunk)
79
+ newline_count += chunk.count(b"\n")
80
+ payload = b"".join(reversed(chunks))
81
+ lines = payload.splitlines(keepends=True)
82
+ selected = b"".join(lines[-limit:])
83
+ return io.StringIO(selected.decode("utf-8", errors="replace"))
84
+
55
85
 
56
86
  def _sanitize_text(value: object) -> str:
57
87
  """Return display text with terminal control sequences made inert.
@@ -382,8 +412,8 @@ def format_transcript(
382
412
 
383
413
  *fmt* is an optional explicit format hint (``"codex"`` or ``"claude"``).
384
414
  When omitted (the default), the format is auto-detected from the record
385
- types actually present in the stream, so a Codex rollout whose leading
386
- ``session_meta`` header was stripped by ``tail`` still renders as Codex.
415
+ types actually present in the stream, so a bounded Codex rollout whose
416
+ leading ``session_meta`` header was omitted still renders as Codex.
387
417
 
388
418
  Callers should write each chunk to stdout, e.g.::
389
419
 
@@ -670,7 +700,15 @@ def main(argv: list[str] | None = None) -> None:
670
700
  if not jsonl_path.exists():
671
701
  print(f"File not found: {jsonl_path}", file=sys.stderr)
672
702
  sys.exit(1)
673
- stream = jsonl_path
703
+ try:
704
+ stream = (
705
+ _tail_utf8_lines(jsonl_path, preview_limit)
706
+ if preview_limit is not None
707
+ else jsonl_path
708
+ )
709
+ except OSError:
710
+ print(f"Could not read: {jsonl_path}", file=sys.stderr)
711
+ sys.exit(1)
674
712
 
675
713
  try:
676
714
  if preview_limit is not None:
@@ -100,6 +100,7 @@ from railmux.ui.workspace import (
100
100
  COMPACT_RESIZE_OPTION,
101
101
  DUAL_SIDEBAR_MIN_WIDTH,
102
102
  DUAL_SIDEBAR_PERCENT,
103
+ DisplayTransportKind,
103
104
  MINIMUM_AGENT_PANE_SIZE,
104
105
  SINGLE_SIDEBAR_PERCENT,
105
106
  SlotRestoreState,
@@ -1177,6 +1178,7 @@ class App:
1177
1178
  *,
1178
1179
  mode_key: str | None = None,
1179
1180
  project_key: str | None = None,
1181
+ sync_transcript_source: bool = True,
1180
1182
  ) -> None:
1181
1183
  """Update one slot, painting sidebar highlights only for the Target."""
1182
1184
  slot.active_session_id = session_id
@@ -1202,7 +1204,8 @@ class App:
1202
1204
  )
1203
1205
  elif session_id is None:
1204
1206
  slot.project_key = None
1205
- self._sync_slot_transcript_source(slot, session_id, tmux_name)
1207
+ if sync_transcript_source:
1208
+ self._sync_slot_transcript_source(slot, session_id, tmux_name)
1206
1209
  self._paint_slot_active_target(slot, session_id, tmux_name)
1207
1210
 
1208
1211
  def _sync_slot_transcript_source(
@@ -1296,7 +1299,8 @@ class App:
1296
1299
  def _set_active_target(self, session_id: str | None,
1297
1300
  tmux_name: str | None, *,
1298
1301
  mode_key: str | None = None,
1299
- project_key: str | None = None) -> None:
1302
+ project_key: str | None = None,
1303
+ sync_transcript_source: bool = True) -> None:
1300
1304
  """Compatibility entry point for the currently exposed primary slot."""
1301
1305
  self._set_slot_active_target(
1302
1306
  self._primary_slot,
@@ -1304,6 +1308,7 @@ class App:
1304
1308
  tmux_name,
1305
1309
  mode_key=mode_key,
1306
1310
  project_key=project_key,
1311
+ sync_transcript_source=sync_transcript_source,
1307
1312
  )
1308
1313
 
1309
1314
  def _set_active_tmux_target(
@@ -2104,10 +2109,19 @@ class App:
2104
2109
  }
2105
2110
  if slot is self._primary_slot:
2106
2111
  self._set_active_target(
2107
- session.session_id, None, **target_kwargs)
2112
+ session.session_id,
2113
+ None,
2114
+ sync_transcript_source=False,
2115
+ **target_kwargs,
2116
+ )
2108
2117
  else:
2109
2118
  self._set_slot_active_target(
2110
- slot, session.session_id, None, **target_kwargs)
2119
+ slot,
2120
+ session.session_id,
2121
+ None,
2122
+ sync_transcript_source=False,
2123
+ **target_kwargs,
2124
+ )
2111
2125
  if not self._show_attention_status(session.attention):
2112
2126
  self._set_status(
2113
2127
  f"≡ Previewing {session.display_title} (current history)")
@@ -2197,11 +2211,11 @@ class App:
2197
2211
  import sys as _sys
2198
2212
  mouse = self._less_mouse_flag
2199
2213
  slot = slot or self._agent_workspace().target
2200
- # Tail the last 2000 lines so large sessions appear instantly. Tailing
2201
- # drops the leading ``session_meta`` record, so a long Codex rollout
2202
- # would otherwise be auto-detected as Claude and render blank. Pass the
2203
- # format explicitly from SessionMeta.session_type so the parser doesn't
2204
- # have to guess from the first tailed record (#5).
2214
+ # The renderer reads the last 2000 records itself so large sessions
2215
+ # appear instantly without launching a separate ``tail`` process.
2216
+ # This drops the leading ``session_meta`` record from long Codex
2217
+ # rollouts, so pass the format explicitly from SessionMeta rather than
2218
+ # asking the bounded input to auto-detect it (#5).
2205
2219
  fmt = "codex" if session_type == "codex" else "claude"
2206
2220
  path = shlex.quote(str(jsonl_path))
2207
2221
  python = shlex.quote(_sys.executable)
@@ -2210,9 +2224,8 @@ class App:
2210
2224
  # and suppress any user-configured input pre/postprocessors.
2211
2225
  less_env = ("LESSSECURE=1 LESSHISTFILE=- "
2212
2226
  "LESSOPEN= LESSCLOSE=")
2213
- cmd = (f"tail -n 2000 {path} | "
2214
- f"{python} -m railmux.transcript --format {fmt} "
2215
- f"--preview-limit 2000 - | "
2227
+ cmd = (f"{python} -m railmux.transcript --format {fmt} "
2228
+ f"--preview-limit 2000 {path} | "
2216
2229
  f"{less_env} less -R +G {mouse}").rstrip()
2217
2230
  restore = slot.restore_state
2218
2231
  controller = getattr(self, "_railmux_pane_id", None)
@@ -2225,33 +2238,58 @@ class App:
2225
2238
  f"{shlex.quote(controller)} -- "
2226
2239
  f"{shlex.quote(restore_sequence)}"
2227
2240
  )
2228
- # In swap mode ``slot.pane_id`` is the real provider pane. Return it
2229
- # home before the destructive ``respawn-pane -k`` below; only the
2230
- # display placeholder may be replaced by the transcript viewer.
2231
- if not self._display_transport().prepare_preview(slot):
2232
- self._set_status(
2233
- "failed to return the agent home before transcript preview",
2234
- "error",
2235
- )
2236
- return False
2237
- self._sync_slot_transcript_source(slot, None, None)
2238
- if slot.pane_id and tmux_ctl.pane_alive(slot.pane_id):
2241
+ # A preview-to-preview switch keeps the same Railmux-owned outer pane,
2242
+ # whose provider and SSH transcript markers were already cleared by
2243
+ # the first transition. ``respawn-pane`` is itself the bounded
2244
+ # liveness check, so do not launch redundant tmux clients on this hot
2245
+ # path. Entering preview from a live agent still uses the complete
2246
+ # return-home identity transaction below.
2247
+ replacing_preview = bool(
2248
+ slot.in_history_mode
2249
+ and slot.pane_id
2250
+ and slot.agent_tmux_name is None
2251
+ and slot.swap_state is None
2252
+ and slot.transport_kind is DisplayTransportKind.NESTED
2253
+ )
2254
+ if replacing_preview:
2255
+ assert slot.pane_id is not None
2239
2256
  if not tmux_ctl.respawn_pane(slot.pane_id, cmd):
2240
2257
  self._set_status("failed to respawn right pane for transcript")
2241
2258
  return False
2242
2259
  else:
2243
- new_id = tmux_ctl.split_window_h(cmd, size_percent=70, detached=True)
2244
- if not new_id:
2245
- self._set_status("failed to create right pane for transcript")
2260
+ # In swap mode ``slot.pane_id`` is the real provider pane. Return
2261
+ # it home before the destructive ``respawn-pane -k`` below; only
2262
+ # the display placeholder may be replaced by the viewer.
2263
+ if not self._display_transport().prepare_preview(slot):
2264
+ self._set_status(
2265
+ "failed to return the agent home before transcript preview",
2266
+ "error",
2267
+ )
2246
2268
  return False
2247
- slot.pane_id = new_id
2248
- self._set_railmux_focus(self._railmux_has_focus, force_border=True)
2269
+ self._sync_slot_transcript_source(slot, None, None)
2270
+ # prepare_preview already proved an existing pane immediately
2271
+ # before this mutation. A race now fails in respawn-pane rather
2272
+ # than paying for a second process-wide list-panes query.
2273
+ if slot.pane_id:
2274
+ if not tmux_ctl.respawn_pane(slot.pane_id, cmd):
2275
+ self._set_status("failed to respawn right pane for transcript")
2276
+ return False
2277
+ else:
2278
+ new_id = tmux_ctl.split_window_h(
2279
+ cmd, size_percent=70, detached=True)
2280
+ if not new_id:
2281
+ self._set_status("failed to create right pane for transcript")
2282
+ return False
2283
+ slot.pane_id = new_id
2284
+ self._set_railmux_focus(
2285
+ self._railmux_has_focus, force_border=True)
2249
2286
  # The display pane is now showing a transcript, not an agent session.
2250
2287
  slot.agent_tmux_name = None
2251
2288
  slot.mode_key = self._current_mode_key()
2252
- self._set_divider_active(
2253
- not getattr(self, "_railmux_has_focus", True), force=True)
2254
- self._install_tmux_bindings()
2289
+ if not replacing_preview:
2290
+ self._set_divider_active(
2291
+ not getattr(self, "_railmux_has_focus", True), force=True)
2292
+ self._install_tmux_bindings()
2255
2293
  return True
2256
2294
 
2257
2295
  def _sync_sidebar_to_agent_project(self, tmux_name: str | None) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev32
3
+ Version: 0.4.0.dev33
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
@@ -655,7 +655,10 @@ def test_show_transcript_codex_passes_explicit_format(monkeypatch):
655
655
  app, captured = _transcript_app(monkeypatch)
656
656
  assert app._show_transcript(Path("/tmp/r.jsonl"), session_type="codex")
657
657
  assert captured["prepared"] is True
658
- assert "railmux.transcript --format codex --preview-limit 2000 -" in captured["cmd"]
658
+ assert (
659
+ "railmux.transcript --format codex --preview-limit 2000 /tmp/r.jsonl"
660
+ in captured["cmd"]
661
+ )
659
662
 
660
663
 
661
664
  def test_show_transcript_claude_passes_explicit_format(monkeypatch):
@@ -670,8 +673,8 @@ def test_show_transcript_uses_secure_read_only_pager_and_quotes_python(monkeypat
670
673
  assert app._show_transcript(Path("/tmp/a file.jsonl"), session_type="codex")
671
674
  cmd = captured["cmd"]
672
675
  assert "'/tmp/Python Dir/python' -m railmux.transcript" in cmd
673
- assert "tail -n 2000 '/tmp/a file.jsonl'" in cmd
674
- assert "--preview-limit 2000" in cmd
676
+ assert "tail -n" not in cmd
677
+ assert "--preview-limit 2000 '/tmp/a file.jsonl'" in cmd
675
678
  assert "LESSSECURE=1" in cmd
676
679
  assert "LESSHISTFILE=-" in cmd
677
680
  assert "LESSOPEN= LESSCLOSE=" in cmd
@@ -1302,6 +1302,14 @@ def test_preview_targets_explicit_active_secondary_slot():
1302
1302
  session_type="claude",
1303
1303
  slot=app._workspace.secondary,
1304
1304
  )
1305
+ app._set_slot_active_target.assert_called_once_with(
1306
+ app._workspace.secondary,
1307
+ "preview-id",
1308
+ None,
1309
+ mode_key="claude",
1310
+ project_key="project",
1311
+ sync_transcript_source=False,
1312
+ )
1305
1313
  assert app._workspace.secondary.in_history_mode is True
1306
1314
 
1307
1315
 
@@ -1332,7 +1340,111 @@ def test_codex_preview_resolves_newest_rewind_representative():
1332
1340
  app._show_transcript.assert_called_once_with(
1333
1341
  current.jsonl_path, session_type="codex")
1334
1342
  app._set_active_target.assert_called_once_with(
1335
- "current-id", None, mode_key="codex", project_key="project")
1343
+ "current-id",
1344
+ None,
1345
+ mode_key="codex",
1346
+ project_key="project",
1347
+ sync_transcript_source=False,
1348
+ )
1349
+
1350
+
1351
+ def test_repeat_preview_reuses_owned_pane_without_revalidating_chrome(
1352
+ monkeypatch, tmp_path,
1353
+ ):
1354
+ """The stopped-preview hot path launches only the replacement viewer."""
1355
+ app = App.__new__(App)
1356
+ app._workspace = AgentWorkspace()
1357
+ slot = app._workspace.primary
1358
+ slot.pane_id = "%2"
1359
+ slot.in_history_mode = True
1360
+ slot.agent_tmux_name = None
1361
+ slot.swap_state = None
1362
+ app._less_mouse_flag = ""
1363
+ app._display_transport = MagicMock()
1364
+ app._sync_slot_transcript_source = MagicMock()
1365
+ app._set_divider_active = MagicMock()
1366
+ app._install_tmux_bindings = MagicMock()
1367
+ app._set_status = MagicMock()
1368
+ commands = []
1369
+ monkeypatch.setattr(
1370
+ tmux_ctl,
1371
+ "pane_alive",
1372
+ lambda _pane: pytest.fail("repeat preview must not list every pane"),
1373
+ )
1374
+ monkeypatch.setattr(
1375
+ tmux_ctl,
1376
+ "respawn_pane",
1377
+ lambda pane, command: commands.append((pane, command)) or True,
1378
+ )
1379
+
1380
+ assert app._show_transcript(
1381
+ tmp_path / "next.jsonl", session_type="claude")
1382
+
1383
+ assert len(commands) == 1
1384
+ assert commands[0][0] == "%2"
1385
+ app._display_transport.assert_not_called()
1386
+ app._sync_slot_transcript_source.assert_not_called()
1387
+ app._set_divider_active.assert_not_called()
1388
+ app._install_tmux_bindings.assert_not_called()
1389
+
1390
+
1391
+ def test_repeat_preview_respawn_failure_keeps_safe_existing_state(
1392
+ monkeypatch, tmp_path,
1393
+ ):
1394
+ app = App.__new__(App)
1395
+ app._workspace = AgentWorkspace()
1396
+ slot = app._workspace.primary
1397
+ slot.pane_id = "%2"
1398
+ slot.in_history_mode = True
1399
+ slot.agent_tmux_name = None
1400
+ slot.swap_state = None
1401
+ app._less_mouse_flag = ""
1402
+ app._display_transport = MagicMock()
1403
+ app._sync_slot_transcript_source = MagicMock()
1404
+ app._set_divider_active = MagicMock()
1405
+ app._install_tmux_bindings = MagicMock()
1406
+ app._set_status = MagicMock()
1407
+ monkeypatch.setattr(tmux_ctl, "respawn_pane", lambda *_args: False)
1408
+
1409
+ assert not app._show_transcript(
1410
+ tmp_path / "missing.jsonl", session_type="claude")
1411
+
1412
+ assert slot.pane_id == "%2"
1413
+ assert slot.in_history_mode is True
1414
+ app._display_transport.assert_not_called()
1415
+ app._sync_slot_transcript_source.assert_not_called()
1416
+ app._set_status.assert_called_once_with(
1417
+ "failed to respawn right pane for transcript")
1418
+
1419
+
1420
+ def test_repeat_preview_with_inconsistent_transport_uses_full_safety_path(
1421
+ monkeypatch, tmp_path,
1422
+ ):
1423
+ app = App.__new__(App)
1424
+ app._workspace = AgentWorkspace()
1425
+ slot = app._workspace.primary
1426
+ slot.pane_id = "%2"
1427
+ slot.in_history_mode = True
1428
+ slot.agent_tmux_name = None
1429
+ slot.swap_state = None
1430
+ slot.transport_kind = DisplayTransportKind.SWAP
1431
+ app._less_mouse_flag = ""
1432
+ transport = MagicMock()
1433
+ transport.prepare_preview.return_value = True
1434
+ app._display_transport = MagicMock(return_value=transport)
1435
+ app._sync_slot_transcript_source = MagicMock()
1436
+ app._set_divider_active = MagicMock()
1437
+ app._install_tmux_bindings = MagicMock()
1438
+ app._set_status = MagicMock()
1439
+ monkeypatch.setattr(tmux_ctl, "respawn_pane", lambda *_args: True)
1440
+
1441
+ assert app._show_transcript(
1442
+ tmp_path / "next.jsonl", session_type="claude")
1443
+
1444
+ transport.prepare_preview.assert_called_once_with(slot)
1445
+ app._sync_slot_transcript_source.assert_called_once_with(slot, None, None)
1446
+ app._set_divider_active.assert_called_once_with(False, force=True)
1447
+ app._install_tmux_bindings.assert_called_once_with()
1336
1448
 
1337
1449
 
1338
1450
  def test_private_history_restore_key_routes_exact_slot_before_modal_checks():
@@ -1357,8 +1469,9 @@ def test_live_transcript_viewer_exit_signals_exact_slot_restore(
1357
1469
  app._less_mouse_flag = ""
1358
1470
  app._railmux_pane_id = "%1"
1359
1471
  app._railmux_has_focus = True
1360
- app._display_transport = MagicMock(
1361
- return_value=SimpleNamespace(prepare_preview=lambda _slot: True))
1472
+ transport = MagicMock()
1473
+ transport.prepare_preview.return_value = True
1474
+ app._display_transport = MagicMock(return_value=transport)
1362
1475
  app._sync_slot_transcript_source = MagicMock()
1363
1476
  app._set_divider_active = MagicMock()
1364
1477
  app._install_tmux_bindings = MagicMock()
@@ -1377,6 +1490,10 @@ def test_live_transcript_viewer_exit_signals_exact_slot_restore(
1377
1490
  assert "tmux send-keys -l -t %1" in commands[0][1]
1378
1491
  assert "\x1b[42~" in commands[0][1]
1379
1492
  assert slot.agent_tmux_name is None
1493
+ transport.prepare_preview.assert_called_once_with(slot)
1494
+ app._sync_slot_transcript_source.assert_called_once_with(slot, None, None)
1495
+ app._set_divider_active.assert_called_once_with(False, force=True)
1496
+ app._install_tmux_bindings.assert_called_once_with()
1380
1497
 
1381
1498
 
1382
1499
  def test_closed_live_history_restores_saved_agent_to_same_slot(monkeypatch):
@@ -160,6 +160,72 @@ def _require_tmux(minimum: tuple[int, int], feature: str) -> None:
160
160
  )
161
161
 
162
162
 
163
+ def test_real_transcript_preview_respawns_one_owned_pane(
164
+ isolated_tmux, tmp_path,
165
+ ):
166
+ """Exercise the no-tail viewer and its repeat-preview tmux primitive."""
167
+ if shutil.which("less") is None:
168
+ pytest.skip("less is not installed")
169
+ _session_name, controller_pane, socket_path = isolated_tmux
170
+ first = tmp_path / "first.jsonl"
171
+ second = tmp_path / "second.jsonl"
172
+ first.write_text(
173
+ '{"type":"user","message":{"role":"user",'
174
+ '"content":"first-preview"}}\n',
175
+ encoding="utf-8",
176
+ )
177
+ second.write_text(
178
+ '{"type":"user","message":{"role":"user",'
179
+ '"content":"second-preview"}}\n',
180
+ encoding="utf-8",
181
+ )
182
+
183
+ def viewer(path: Path) -> str:
184
+ producer = shlex.join((
185
+ sys.executable,
186
+ "-m",
187
+ "railmux.transcript",
188
+ "--format",
189
+ "claude",
190
+ "--preview-limit",
191
+ "2000",
192
+ str(path),
193
+ ))
194
+ return (
195
+ f"{producer} | LESSSECURE=1 LESSHISTFILE=- "
196
+ "LESSOPEN= LESSCLOSE= less -R +G"
197
+ )
198
+
199
+ pane_id = subprocess.check_output(
200
+ [
201
+ "tmux", "-S", socket_path, "split-window", "-d", "-h",
202
+ "-P", "-F", "#{pane_id}", "-t", controller_pane,
203
+ viewer(first),
204
+ ],
205
+ text=True,
206
+ ).strip()
207
+
208
+ def pane_contains(value: str) -> bool:
209
+ capture = subprocess.run(
210
+ ["tmux", "-S", socket_path, "capture-pane", "-p", "-t", pane_id],
211
+ capture_output=True,
212
+ text=True,
213
+ check=False,
214
+ )
215
+ return capture.returncode == 0 and value in capture.stdout
216
+
217
+ assert _wait_until(lambda: pane_contains("first-preview"))
218
+ subprocess.run(
219
+ [
220
+ "tmux", "-S", socket_path, "respawn-pane", "-k", "-t", pane_id,
221
+ viewer(second),
222
+ ],
223
+ check=True,
224
+ )
225
+ assert _wait_until(lambda: pane_contains("second-preview"))
226
+ assert not pane_contains("first-preview")
227
+
228
+
163
229
  def test_real_transparent_windows_relay_attaches_and_detaches(isolated_tmux):
164
230
  session_name, _pane_id, socket_path = isolated_tmux
165
231
  target = TmuxServerTarget(
@@ -11,6 +11,7 @@ from railmux.transcript import (
11
11
  BOLD,
12
12
  CLAUDE_ACCENT,
13
13
  RESET,
14
+ _tail_utf8_lines,
14
15
  _is_real_user,
15
16
  _render_user,
16
17
  _render_assistant_blocks,
@@ -612,6 +613,43 @@ def test_main_preview_banner_and_footer(tmp_path, capsys):
612
613
  assert "/ search" in output and "q close" in output
613
614
 
614
615
 
616
+ def test_tail_utf8_lines_reads_complete_final_records_across_blocks(tmp_path):
617
+ jsonl = tmp_path / "large.jsonl"
618
+ long_line = "older-" + "x" * 70_000
619
+ jsonl.write_text(
620
+ "\n".join((long_line, "保留一", "保留二", "保留三")),
621
+ encoding="utf-8",
622
+ )
623
+
624
+ stream = _tail_utf8_lines(jsonl, 3)
625
+
626
+ assert stream.read().splitlines() == ["保留一", "保留二", "保留三"]
627
+
628
+
629
+ def test_main_preview_limit_bounds_a_direct_file_without_external_tail(
630
+ tmp_path, capsys,
631
+ ):
632
+ jsonl = tmp_path / "claude.jsonl"
633
+ _write_jsonl(jsonl, [
634
+ {
635
+ "type": "user",
636
+ "message": {"role": "user", "content": f"message-{index}"},
637
+ }
638
+ for index in range(5)
639
+ ])
640
+
641
+ main([
642
+ "transcript", "--format", "claude", "--preview-limit", "2",
643
+ str(jsonl),
644
+ ])
645
+
646
+ output = capsys.readouterr().out
647
+ assert "message-0" not in output
648
+ assert "message-2" not in output
649
+ assert "message-3" in output
650
+ assert "message-4" in output
651
+
652
+
615
653
  def test_main_rejects_bad_preview_limit(capsys):
616
654
  with pytest.raises(SystemExit) as exc:
617
655
  main(["transcript", "--preview-limit", "nope", "-"])
@@ -1 +0,0 @@
1
- __version__ = "0.4.0.dev32"
File without changes
File without changes
File without changes
File without changes