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.
- {railmux-0.4.2.dev5/src/railmux.egg-info → railmux-0.4.2.dev7}/PKG-INFO +1 -1
- railmux-0.4.2.dev7/src/railmux/__init__.py +1 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_server.py +42 -21
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/transcript.py +303 -44
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_server.py +32 -4
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_transcript.py +110 -0
- railmux-0.4.2.dev5/src/railmux/__init__.py +0 -1
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/LICENSE +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/MANIFEST.in +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/README.md +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/pyproject.toml +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/setup.cfg +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/__main__.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/background_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/cli.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/diagnostic_contract.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/discovery.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/favorites.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/image_paste.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/launcher.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/local_open.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/models.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/modes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/preview_pager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/project_favorites.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/release_version.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/renames.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/self_update.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/session_lease.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/settings.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ssh_preflight.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/terminal_screen.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_capabilities.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/app.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_attach_relay.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_install_log.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_msys2.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_paths.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux/windows_ui_transition.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/app_test_harness.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_codex_placeholder.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_quit.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_restart_restore.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_app_running_recovery.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_background_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_claude_continuation.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_cli.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_code_map.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_config_cli.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_discovery.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_display_transport.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_bootstrap.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_history.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_protocol.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_selection.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fast_display_terminal.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_help_agent.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_image_paste.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_keymap.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_launcher.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_local_open.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_modals.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_modes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_new_project.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_options.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_path_codec.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_preview_pager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_project_favorites.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_notes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_version.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_remote_config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_renames.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_restart_state.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_running_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_running_sort.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_self_update.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_cache.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_index.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_session_lease.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_settings.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_sidebar.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_status.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_statusbar.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_system_deps.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_capabilities.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_integration.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_widgets.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_attach_relay.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_install_log.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_msys2.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_paths.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_synchronized_screen.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_ui_transition.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/release_notes.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/wheel_smoke.py +0 -0
- {railmux-0.4.2.dev5 → railmux-0.4.2.dev7}/tools/windows_archive_smoke.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
|
1809
|
+
if not anchors:
|
|
1804
1810
|
return total
|
|
1805
|
-
|
|
1806
|
-
|
|
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(
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
534
|
+
lines = _markdown_lines(
|
|
535
|
+
lines, None if width is None else width - 2)
|
|
295
536
|
if not lines:
|
|
296
537
|
return ""
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
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,
|
|
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,
|
|
1909
|
+
pyte, marker, 10, allow_stale=False
|
|
1882
1910
|
) is not None
|
|
1883
|
-
assert len(calls) ==
|
|
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()
|