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.
- {railmux-0.4.0.dev32/src/railmux.egg-info → railmux-0.4.0.dev33}/PKG-INFO +1 -1
- railmux-0.4.0.dev33/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/transcript.py +46 -8
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/app.py +69 -31
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_delete.py +6 -3
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_focus_highlight.py +120 -3
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_integration.py +66 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_transcript.py +38 -0
- railmux-0.4.0.dev32/src/railmux/__init__.py +0 -1
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/LICENSE +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/README.md +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/pyproject.toml +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/setup.cfg +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/cli.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/discovery.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/session_lease.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_install_log.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_msys2.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_paths.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux/windows_ui_transition.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_session_lease.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_install_log.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_msys2.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_paths.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_ui_transition.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/wheel_smoke.py +0 -0
- {railmux-0.4.0.dev32 → railmux-0.4.0.dev33}/tools/windows_archive_smoke.py +0 -0
|
@@ -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
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
|
386
|
-
``session_meta`` header was
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
2201
|
-
#
|
|
2202
|
-
#
|
|
2203
|
-
# format explicitly from SessionMeta
|
|
2204
|
-
#
|
|
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"
|
|
2214
|
-
f"
|
|
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
|
-
#
|
|
2229
|
-
#
|
|
2230
|
-
#
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
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
|
|
2248
|
-
|
|
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
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
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:
|
|
@@ -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
|
|
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
|
|
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",
|
|
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
|
-
|
|
1361
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|