Repository: marlocarlo/psmux
Branch: master
Commit: 9ed1f48e8250
Files: 576
Total size: 7.9 MB
Directory structure:
gitextract_ld4ke3i2/
├── .cargo/
│ └── config.toml
├── .github/
│ ├── ASSETS.md
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
├── choco-pkg/
│ ├── psmux.nuspec
│ └── tools/
│ ├── chocolateyinstall.ps1
│ └── chocolateyuninstall.ps1
├── crates/
│ ├── portable-pty-psmux/
│ │ ├── .cargo-ok
│ │ ├── .cargo_vcs_info.json
│ │ ├── Cargo.toml
│ │ ├── Cargo.toml.orig
│ │ ├── LICENSE.md
│ │ ├── README.md
│ │ ├── examples/
│ │ │ ├── bash.rs
│ │ │ ├── narrow.rs
│ │ │ ├── whoami.rs
│ │ │ └── whoami_async.rs
│ │ └── src/
│ │ ├── cmdbuilder.rs
│ │ ├── lib.rs
│ │ ├── serial.rs
│ │ ├── unix.rs
│ │ └── win/
│ │ ├── conpty.rs
│ │ ├── mod.rs
│ │ ├── procthreadattr.rs
│ │ └── psuedocon.rs
│ └── vt100-psmux/
│ ├── .cargo-ok
│ ├── .cargo_vcs_info.json
│ ├── CHANGELOG.md
│ ├── Cargo.toml
│ ├── Cargo.toml.orig
│ ├── LICENSE
│ ├── README.md
│ └── src/
│ ├── attrs.rs
│ ├── callbacks.rs
│ ├── cell.rs
│ ├── grid.rs
│ ├── lib.rs
│ ├── parser.rs
│ ├── perform.rs
│ ├── row.rs
│ ├── screen.rs
│ └── term.rs
├── docker/
│ ├── Dockerfile
│ ├── Profile/
│ │ └── Microsoft.PowerShell_profile.ps1
│ ├── README.md
│ ├── Run-PsmuxDev.ps1
│ └── Tools/
│ ├── ImportVsDevEnv.ps1
│ ├── InstallAll.ps1
│ ├── InstallGit.ps1
│ ├── InstallOpenSSH.ps1
│ ├── InstallRust.ps1
│ ├── InstallVsBuildTools.ps1
│ └── StartContainer.ps1
├── docs/
│ ├── claude-code.md
│ ├── compatibility.md
│ ├── configuration.md
│ ├── control-mode.md
│ ├── faq.md
│ ├── features.md
│ ├── integration.md
│ ├── iterm2-control-mode.md
│ ├── keybindings.md
│ ├── mouse-ssh.md
│ ├── multi-shell.md
│ ├── pane-titles.md
│ ├── performance.md
│ ├── plugins.md
│ ├── preview.md
│ ├── scripting.md
│ ├── tmux_args_reference.md
│ └── warm-sessions.md
├── examples/
│ ├── crossterm_sgr_diag.rs
│ ├── enter_diag.rs
│ ├── key_diag.rs
│ ├── key_test.rs
│ ├── latency_harness.rs
│ ├── pipeline_diag.rs
│ ├── pty_diag.rs
│ ├── pty_sgr_diag.rs
│ ├── ratatui_render_diag.rs
│ └── test_cursor_debug.rs
├── installer/
│ └── psmux.nsi
├── packages/
│ └── chocolatey/
│ ├── psmux.nuspec
│ └── tools/
│ ├── chocolateyinstall.ps1
│ └── chocolateyuninstall.ps1
├── psmux.json
├── rust-toolchain.toml
├── scoop/
│ └── psmux.json
├── scripts/
│ ├── build.ps1
│ ├── install.ps1
│ ├── pmux-title.ps1
│ ├── pmux-title.sh
│ ├── publish-choco.ps1
│ └── uninstall.ps1
├── src/
│ ├── cli.rs
│ ├── client.rs
│ ├── clipboard.rs
│ ├── commands.rs
│ ├── config.rs
│ ├── control.rs
│ ├── copy_mode.rs
│ ├── cross_session.rs
│ ├── cross_session_server.rs
│ ├── debug_log.rs
│ ├── format.rs
│ ├── help.rs
│ ├── input.rs
│ ├── layout.rs
│ ├── main.rs
│ ├── pane.rs
│ ├── platform.rs
│ ├── popup.rs
│ ├── preview.rs
│ ├── proxy_pane.rs
│ ├── rendering.rs
│ ├── server/
│ │ ├── connection.rs
│ │ ├── helpers.rs
│ │ ├── mod.rs
│ │ ├── option_catalog.rs
│ │ └── options.rs
│ ├── session.rs
│ ├── ssh_input.rs
│ ├── style.rs
│ ├── tree.rs
│ ├── types.rs
│ ├── util.rs
│ ├── warm_pane_sync.rs
│ └── window_ops.rs
├── tests/
│ ├── _batch_runner.ps1
│ ├── _full_run.ps1
│ ├── _launch_debug.bat
│ ├── _run_batch3.ps1
│ ├── alt_emit.ps1
│ ├── alt_emit_inner.ps1
│ ├── alt_emit_simple.ps1
│ ├── battle_test.ps1
│ ├── battle_test.py
│ ├── bench_squelch_cwd.ps1
│ ├── bench_startup_exit.ps1
│ ├── burst_bench2.cs
│ ├── burst_benchmark.cs
│ ├── cursor_bench.cs
│ ├── debug_paste_trace.ps1
│ ├── destructive_test.ps1
│ ├── diag_cursor_claude.ps1
│ ├── diag_cursor_raw.ps1
│ ├── diag_enter_selfcontained.ps1
│ ├── diag_key_events.ps1
│ ├── diag_scroll_inject.ps1
│ ├── disable_processed_input.ps1
│ ├── injector.cs
│ ├── injector_batch.cs
│ ├── inspect_issue263_dump.ps1
│ ├── inspect_issue263_runs.ps1
│ ├── investigate_274_attach_kill.ps1
│ ├── investigate_274_clean_recovery.ps1
│ ├── investigate_274_long.ps1
│ ├── investigate_274_node_daemon.ps1
│ ├── investigate_274_unresponsive_proc.ps1
│ ├── investigate_274_wedge.ps1
│ ├── issue246_emitter.py
│ ├── mouse_diag.ps1
│ ├── mouse_injector.cs
│ ├── repro_enter_bugs.ps1
│ ├── repro_issue303.ps1
│ ├── repro_issue303b.ps1
│ ├── repro_issue303c.ps1
│ ├── repro_preview_compare.ps1
│ ├── repro_preview_layout.ps1
│ ├── repro_pstop_preview.ps1
│ ├── repro_seven_panes.ps1
│ ├── run_all_tests.ps1
│ ├── run_batch_fast.ps1
│ ├── run_fmt_test.ps1
│ ├── test_advanced.ps1
│ ├── test_agent_teams_e2e.ps1
│ ├── test_all.ps1
│ ├── test_all.sh
│ ├── test_alt_key.ps1
│ ├── test_bell_activity_silence.ps1
│ ├── test_bind_key.ps1
│ ├── test_bind_pipe_key.ps1
│ ├── test_bug_detection.ps1
│ ├── test_bugfixes.ps1
│ ├── test_burst_typing_benchmark.ps1
│ ├── test_capture_pane.ps1
│ ├── test_cc_iterm2_compat.ps1
│ ├── test_choose_tree_preview.ps1
│ ├── test_cjk_paste_split.ps1
│ ├── test_claude_agent_teams.ps1
│ ├── test_claude_compat_fixes.ps1
│ ├── test_claude_cursor_diag.ps1
│ ├── test_claude_mouse.ps1
│ ├── test_cli_flag_parity.ps1
│ ├── test_cli_handlers.ps1
│ ├── test_cli_mega_suite.ps1
│ ├── test_combined_flags.ps1
│ ├── test_config.ps1
│ ├── test_config_exhaustive_cli.ps1
│ ├── test_config_exhaustive_tcp.ps1
│ ├── test_config_exhaustive_tui.ps1
│ ├── test_config_plugin_loading.ps1
│ ├── test_conpty_mouse.ps1
│ ├── test_control_mode.ps1
│ ├── test_copy_mode_advanced.ps1
│ ├── test_copy_mode_bracket_paragraph.ps1
│ ├── test_copy_mode_full.ps1
│ ├── test_cross_session_join_pane.ps1
│ ├── test_cross_shell_backslash.ps1
│ ├── test_cursor_fallback.ps1
│ ├── test_cursor_style.ps1
│ ├── test_debug_focus.ps1
│ ├── test_default_command_format.ps1
│ ├── test_default_shell_cmd.ps1
│ ├── test_default_shell_wsl.ps1
│ ├── test_display_message_duration.ps1
│ ├── test_e2e_latency.ps1
│ ├── test_env_shim.ps1
│ ├── test_extreme_perf.ps1
│ ├── test_f_flag_config.ps1
│ ├── test_features.ps1
│ ├── test_features2.ps1
│ ├── test_features3.ps1
│ ├── test_features4.ps1
│ ├── test_features5.ps1
│ ├── test_format_engine.ps1
│ ├── test_format_vars.ps1
│ ├── test_full_feature.ps1
│ ├── test_github_issues.ps1
│ ├── test_github_issues_all.ps1
│ ├── test_hide_window_e2e.ps1
│ ├── test_install_speed.ps1
│ ├── test_issue100_key_names.ps1
│ ├── test_issue105_plugin_env_leak.ps1
│ ├── test_issue108_ctrl_tab.ps1
│ ├── test_issue110_popup_scroll.ps1
│ ├── test_issue111_format_cwd.ps1
│ ├── test_issue111_starship_compat.ps1
│ ├── test_issue112_113.ps1
│ ├── test_issue121_shift_enter.ps1
│ ├── test_issue125_per_window_zoom.ps1
│ ├── test_issue125_zoom_flag.ps1
│ ├── test_issue126_client_prefix.ps1
│ ├── test_issue133_hook_append.ps1
│ ├── test_issue133_hook_duplicates.ps1
│ ├── test_issue134_zoom_wrap_nav.ps1
│ ├── test_issue136_auth_failed.ps1
│ ├── test_issue137_default_terminal.ps1
│ ├── test_issue140_kill_pane_focus_loss.ps1
│ ├── test_issue146_list_commands.ps1
│ ├── test_issue146_popup_via_command_prompt.ps1
│ ├── test_issue151_strict_mode.ps1
│ ├── test_issue154_popup_fixes.ps1
│ ├── test_issue15_altgr.ps1
│ ├── test_issue165_prediction_view_style.ps1
│ ├── test_issue167_conpty_probe.ps1
│ ├── test_issue167_repro.ps1
│ ├── test_issue171_layout.ps1
│ ├── test_issue197_exact_text.ps1
│ ├── test_issue197_paste_tilde.ps1
│ ├── test_issue197_paste_validation.ps1
│ ├── test_issue197_win32_paste.ps1
│ ├── test_issue198_cv_unbind_persist.ps1
│ ├── test_issue198_cv_unbind_proof.ps1
│ ├── test_issue198_paste_detection.ps1
│ ├── test_issue198_paste_detection_proof.ps1
│ ├── test_issue19_config.ps1
│ ├── test_issue200_new_session.ps1
│ ├── test_issue200_proof.ps1
│ ├── test_issue200_sendkeys_proof.ps1
│ ├── test_issue201_rename_dialog.ps1
│ ├── test_issue201_win32_tui_proof.ps1
│ ├── test_issue202_cli_routing.ps1
│ ├── test_issue204_stale_port_files.ps1
│ ├── test_issue205_new_session_env.ps1
│ ├── test_issue206_tui_auth.ps1
│ ├── test_issue209_e2e_verify.ps1
│ ├── test_issue209_functional_proof.ps1
│ ├── test_issue209_tmux_parity_deep.ps1
│ ├── test_issue210_gastown_captures.ps1
│ ├── test_issue210_gastown_fixes.ps1
│ ├── test_issue211_pwsh_mouse_selection.ps1
│ ├── test_issue211_win32_mouse.ps1
│ ├── test_issue215_session_persistence.ps1
│ ├── test_issue217_pane_title_identify.ps1
│ ├── test_issue217_win32_tui_proof.ps1
│ ├── test_issue221_run_shell.ps1
│ ├── test_issue226_ctrl_slash.ps1
│ ├── test_issue226_full_proof.ps1
│ ├── test_issue227_remain_on_exit_hooks.ps1
│ ├── test_issue229_window_name_flash.ps1
│ ├── test_issue229_window_name_initial_command.ps1
│ ├── test_issue230_join_pane_and_ctrl_c.ps1
│ ├── test_issue230_join_pane_and_ctrl_c_proof.ps1
│ ├── test_issue230_send_keys_ctrl_c.ps1
│ ├── test_issue231_osc_title_propagation.ps1
│ ├── test_issue232_status_interval.ps1
│ ├── test_issue234_choose_buffer.ps1
│ ├── test_issue234_choose_buffer_proof.ps1
│ ├── test_issue235_display_panes_base_index.ps1
│ ├── test_issue237_final_proof.ps1
│ ├── test_issue237_typing_freeze.ps1
│ ├── test_issue237_typing_freeze_perf.ps1
│ ├── test_issue239_repro.ps1
│ ├── test_issue242_pageup_copy_mode.ps1
│ ├── test_issue244_capture_scrollback.ps1
│ ├── test_issue244_capture_scrollback_proof.ps1
│ ├── test_issue245_mouse_selection.ps1
│ ├── test_issue246_sparse_render.ps1
│ ├── test_issue247_session_picker_digit.ps1
│ ├── test_issue25.ps1
│ ├── test_issue250_root_cause.ps1
│ ├── test_issue253_repro.ps1
│ ├── test_issue253_repro2.ps1
│ ├── test_issue253_repro3.ps1
│ ├── test_issue257_preview.ps1
│ ├── test_issue259_picker_hjkl.ps1
│ ├── test_issue261_cc_verify.ps1
│ ├── test_issue261_proof.ps1
│ ├── test_issue263_box_drawing_color.ps1
│ ├── test_issue263_byte_proof.ps1
│ ├── test_issue263_definitive.ps1
│ ├── test_issue263_dump_state.ps1
│ ├── test_issue263_final.ps1
│ ├── test_issue263_irrefutable.ps1
│ ├── test_issue263_nested.ps1
│ ├── test_issue263_proof.ps1
│ ├── test_issue263_python_raw.ps1
│ ├── test_issue263_raw.ps1
│ ├── test_issue263_raw_bytes.ps1
│ ├── test_issue263_v2.ps1
│ ├── test_issue264_paste_buffer_proof.ps1
│ ├── test_issue264_paste_buffer_repro.ps1
│ ├── test_issue265_argv_backslash.ps1
│ ├── test_issue266_autorename_override.ps1
│ ├── test_issue266_autorename_override_proof.ps1
│ ├── test_issue266_explicit_name.ps1
│ ├── test_issue266_with_python.ps1
│ ├── test_issue268_dump_state.ps1
│ ├── test_issue269_byte_capture.ps1
│ ├── test_issue269_osc94_dropped.ps1
│ ├── test_issue269_osc94_dropped_proof.ps1
│ ├── test_issue271_runtime_set_propagation.ps1
│ ├── test_issue271_warm_pane_history.ps1
│ ├── test_issue271_warm_pane_history_proof.ps1
│ ├── test_issue272_status_format_perf.ps1
│ ├── test_issue273_send_prefix.ps1
│ ├── test_issue274_pane_isolation.ps1
│ ├── test_issue274_sustained_load.ps1
│ ├── test_issue274_tui_wedge_repro.ps1
│ ├── test_issue274_wezterm_repro.ps1
│ ├── test_issue275_detach_client.ps1
│ ├── test_issue275_detach_keystroke.ps1
│ ├── test_issue277_definitive.ps1
│ ├── test_issue277_scroll_controlled.ps1
│ ├── test_issue277_scroll_repro.ps1
│ ├── test_issue277_tcp_scroll.ps1
│ ├── test_issue284_pageup_wsl.ps1
│ ├── test_issue285_nvim_mouse.ps1
│ ├── test_issue286_ime_prefix.ps1
│ ├── test_issue287_german_keyboard.ps1
│ ├── test_issue288_pane_border_status.ps1
│ ├── test_issue295_scroll_regression.ps1
│ ├── test_issue296_nvim_hang.ps1
│ ├── test_issue33_remaining.ps1
│ ├── test_issue36.ps1
│ ├── test_issue41_btab.ps1
│ ├── test_issue42_version.ps1
│ ├── test_issue43_copy_pane_local.ps1
│ ├── test_issue43_prefix_o_l.ps1
│ ├── test_issue44_zoom_buffer.ps1
│ ├── test_issue45_unzoom_cursor.ps1
│ ├── test_issue46_zoom_nav_desync.ps1
│ ├── test_issue47_bare_run.ps1
│ ├── test_issue49_ctlseq.ps1
│ ├── test_issue50_chinese_chars.ps1
│ ├── test_issue52_claude.ps1
│ ├── test_issue52_cursor.ps1
│ ├── test_issue60_native_tui_mouse.ps1
│ ├── test_issue63_status_off.ps1
│ ├── test_issue70_mouse_mru_and_detached.ps1
│ ├── test_issue70_mru_navigation.ps1
│ ├── test_issue70_select_pane_mru.ps1
│ ├── test_issue71_kill_pane_focus.ps1
│ ├── test_issue74_paste.ps1
│ ├── test_issue82_zoom_split_borders.ps1
│ ├── test_issue88_alt_screen_proof.ps1
│ ├── test_issue88_alt_screen_v2.ps1
│ ├── test_issue88_clean_e2e.ps1
│ ├── test_issue88_codex_scrollback.ps1
│ ├── test_issue88_debug_b.ps1
│ ├── test_issue88_debug_v2.ps1
│ ├── test_issue88_fix_proof.ps1
│ ├── test_issue88_irrefutable_proof.ps1
│ ├── test_issue91_ime_paste.ps1
│ ├── test_issue94_split_percent.ps1
│ ├── test_issue95_commands.ps1
│ ├── test_issue98_bracketed_paste.ps1
│ ├── test_issue99_default_shell_bash.ps1
│ ├── test_issues_107_109_110.ps1
│ ├── test_keybinding_options.ps1
│ ├── test_keystroke_injection.ps1
│ ├── test_kill_pane_by_id.ps1
│ ├── test_kill_server.ps1
│ ├── test_kill_tree.ps1
│ ├── test_layout_engine.ps1
│ ├── test_load_buffer_w_clipboard.ps1
│ ├── test_long_paragraph_benchmark.ps1
│ ├── test_mouse_handling.ps1
│ ├── test_mouse_hover.ps1
│ ├── test_named_buffers.ps1
│ ├── test_named_session_parity.ps1
│ ├── test_new_features.ps1
│ ├── test_new_parity_features.ps1
│ ├── test_newsession_flags.ps1
│ ├── test_nsis_installer.ps1
│ ├── test_osc7_pane_path.ps1
│ ├── test_overlay_bugfixes.ps1
│ ├── test_overlay_rendering.ps1
│ ├── test_pane_mru.ps1
│ ├── test_pane_navigation.ps1
│ ├── test_pane_startup_perf.ps1
│ ├── test_parity.ps1
│ ├── test_perf.ps1
│ ├── test_perf_vs_wt.ps1
│ ├── test_picker_digit_jump_all.ps1
│ ├── test_plugins_themes.ps1
│ ├── test_pr118_bugs.ps1
│ ├── test_pr207_claims.ps1
│ ├── test_pr207_compat_bugs.ps1
│ ├── test_pr207_libtmux.py
│ ├── test_pr207_retest.ps1
│ ├── test_pr207_workaround_elimination.ps1
│ ├── test_pr222_223_run_shell_paths.ps1
│ ├── test_pr255_active_border.ps1
│ ├── test_pr255_visual_proof.ps1
│ ├── test_pr27.ps1
│ ├── test_preview_stuck_repro.ps1
│ ├── test_production_readiness.ps1
│ ├── test_pty_stability.ps1
│ ├── test_real_plugins.ps1
│ ├── test_realistic_typing.ps1
│ ├── test_round9.ps1
│ ├── test_run_shell.ps1
│ ├── test_scroll_memory.ps1
│ ├── test_scroll_viewport_proof.ps1
│ ├── test_scroll_viewport_tracking.ps1
│ ├── test_session.ps1
│ ├── test_session_mgmt.ps1
│ ├── test_showw_sendkeys_p.ps1
│ ├── test_spaces_in_paths.ps1
│ ├── test_split_limits.ps1
│ ├── test_split_window_target_focus.ps1
│ ├── test_squelch_visibility.ps1
│ ├── test_startup_exit_bench.ps1
│ ├── test_startup_perf.ps1
│ ├── test_stress.ps1
│ ├── test_stress_50.ps1
│ ├── test_stress_aggressive.ps1
│ ├── test_stress_attached.ps1
│ ├── test_sustained_fast_typing.ps1
│ ├── test_switch_client.ps1
│ ├── test_switch_client_live_proof.ps1
│ ├── test_tab_spacing.ps1
│ ├── test_target_focus_stability.ps1
│ ├── test_tcp_flag_parity.ps1
│ ├── test_tcp_mega_suite.ps1
│ ├── test_theme_rendering.ps1
│ ├── test_tmux_compat.ps1
│ ├── test_tui_exit_cleanup.ps1
│ ├── test_tui_win32_proof.ps1
│ ├── test_typing_benchmark.ps1
│ ├── test_typing_render_latency.ps1
│ ├── test_unbind_key_a.ps1
│ ├── test_vim_nav_keys.ps1
│ ├── test_vt_paste_missing_close.ps1
│ ├── test_vt_paste_ssh_real.ps1
│ ├── test_warm_off.ps1
│ ├── test_warm_pane.ps1
│ ├── test_warm_pane_sync_options.ps1
│ ├── test_warm_session_claim.ps1
│ ├── test_win32_tui_flag_parity.ps1
│ ├── test_win32_tui_mega_proof.ps1
│ ├── test_window_exit_statusbar.ps1
│ ├── test_window_index_prompt.ps1
│ ├── test_wsl_in_pwsh_latency.ps1
│ ├── test_wsl_in_pwsh_latency2.ps1
│ ├── test_wsl_latency.ps1
│ ├── test_wsl_pwsh_latency3.ps1
│ ├── test_wsl_pwsh_latency4.ps1
│ ├── test_wsl_pwsh_latency5.ps1
│ ├── test_zoom_resize.ps1
│ ├── timed_injector.cs
│ ├── tui_helper.ps1
│ ├── typing_bench.cs
│ └── typing_benchmark.cs
└── tests-rs/
├── test_client.rs
├── test_cmdbuilder.rs
├── test_commands.rs
├── test_commands_audit.rs
├── test_commands_new.rs
├── test_config_exhaustive.rs
├── test_config_plugin_paths.rs
├── test_cpr_responder.rs
├── test_flag_parity.rs
├── test_format.rs
├── test_gastown_scenarios.rs
├── test_h1_osc52_clipboard_capture.rs
├── test_h1_osc52_end_to_end.rs
├── test_hide_window.rs
├── test_input.rs
├── test_issue137_env_leak.rs
├── test_issue145_source_file.rs
├── test_issue151_strict_mode.rs
├── test_issue155_output_rendering.rs
├── test_issue155_rendering.rs
├── test_issue155_sgr_attrs.rs
├── test_issue157_bind_key_case.rs
├── test_issue165_prediction_view_style.rs
├── test_issue167_startup_log.rs
├── test_issue169_manual_rename.rs
├── test_issue171_layout_bugs.rs
├── test_issue179_bind_key_uppercase.rs
├── test_issue185_layout_directives.rs
├── test_issue192_command_chaining.rs
├── test_issue193_scroll_enter_copy_mode.rs
├── test_issue196_flag_equals.rs
├── test_issue198_cv_persist.rs
├── test_issue198_unbind_individual.rs
├── test_issue200_new_session.rs
├── test_issue201_rename_dialog.rs
├── test_issue202_switch_client.rs
├── test_issue209_tmux_compat.rs
├── test_issue210_gastown_captures.rs
├── test_issue210_gastown_fixes.rs
├── test_issue215_session_persistence.rs
├── test_issue226_ctrl_slash.rs
├── test_issue227_remain_on_exit_hooks.rs
├── test_issue235_display_panes_base_index.rs
├── test_issue244_capture_scrollback.rs
├── test_issue245_mouse_selection.rs
├── test_issue250_root_cause.rs
├── test_issue265_argv_backslash.rs
├── test_issue266_per_window_autorename.rs
├── test_issue268_set_titles.rs
├── test_issue269_osc94_dropped.rs
├── test_issue271_warm_pane_history.rs
├── test_issue272_format_shell_cache.rs
├── test_issue273_send_prefix.rs
├── test_issue275_detach_client.rs
├── test_issue278_toggle_bool_option.rs
├── test_issue284_pageup_wsl.rs
├── test_issue287_german_keyboard.rs
├── test_issue81_resize_direction.rs
├── test_issue88_alt_screen_toggle.rs
├── test_layout.rs
├── test_mega_unit_coverage.rs
├── test_named_buffers.rs
├── test_new_session_env.rs
├── test_pane_title.rs
├── test_parity.rs
├── test_pr207_compat_bugs.rs
├── test_pr255_active_border.rs
├── test_pr267_backpressure_proof.rs
├── test_run_shell_resolve.rs
├── test_server.rs
├── test_session.rs
├── test_ssh_vt_paste.rs
├── test_vt100_mouse.rs
├── test_vt100_screen.rs
├── test_warm_pane_sync.rs
└── test_zoom_bleed.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .cargo/config.toml
================================================
[alias]
lint = "clippy -- -D warnings"
# Statically link the C runtime so binaries don't need vcruntime140.dll
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
================================================
FILE: .github/ASSETS.md
================================================
# Repository Assets
## Social Card Setup
To enable the social card on GitHub:
1. **Convert SVG to PNG** (if not already done):
- Online: Upload `.github/social-card.svg` to https://cloudconvert.com/svg-to-png
- Or install ImageMagick: `winget install ImageMagick.ImageMagick`
- Then run: `magick .github/social-card.svg .github/social-card.png`
2. **Upload to GitHub**:
- Go to: https://github.com/psmux/psmux/settings
- Scroll to "Social preview"
- Click "Edit" and upload `.github/social-card.png`
- Dimensions: 1280x640px (optimal for social sharing)
## Repository Icon
The `icon.svg` can be used as:
- Project logo in documentation
- Favicon for project websites
- App icon if building a GUI wrapper
### Design Features
**Social Card (`1280x640px`):**
- Dark gradient background (#1a1a2e → #16213e)
- Terminal window with split pane visualization
- psmux branding with cyan accent (#00d9ff)
- Feature badges: tmux-compatible, Windows-native, Rust-powered, No WSL
- PS> prompts to emphasize PowerShell support
**Icon (`512x512px`):**
- Cyan gradient circular background
- Terminal window with 3-pane split layout
- Animated cursor (blinks when viewed as SVG)
- Compact design suitable for various sizes
Both designs emphasize:
- Terminal multiplexing (split panes)
- Windows/PowerShell focus (PS> prompts)
- Modern, professional aesthetic
- Brand color consistency (#00d9ff cyan)
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build ${{ matrix.label }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
label: Windows x64
- target: i686-pc-windows-msvc
label: Windows x86
- target: aarch64-pc-windows-msvc
label: Windows ARM64
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (e.g. v3.3.3) — tag must already exist in the repo'
required: true
type: string
permissions:
contents: write
# Prevent duplicate runs from overwriting release assets / submitting duplicate PRs
concurrency:
group: release-${{ inputs.tag }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build ${{ matrix.label }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
label: Windows x64
artifact: windows-x64
nsis_arch: x64
- target: i686-pc-windows-msvc
label: Windows x86
artifact: windows-x86
nsis_arch: x86
- target: aarch64-pc-windows-msvc
label: Windows ARM64 (Snapdragon)
artifact: windows-arm64
nsis_arch: arm64
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Create release package
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ inputs.tag }}"
$zipName = "psmux-$version-${{ matrix.artifact }}"
$releaseDir = "target/${{ matrix.target }}/release"
New-Item -ItemType Directory -Path $zipName -Force
# Verify all expected binaries exist before copying
foreach ($exe in @("psmux.exe", "pmux.exe", "tmux.exe")) {
$path = Join-Path $releaseDir $exe
if (-not (Test-Path $path)) {
Write-Error "FATAL: Expected binary '$path' not found! Build may have failed silently."
Get-ChildItem $releaseDir -Filter "*.exe" | ForEach-Object { Write-Output " Found: $($_.FullName) ($($_.Length) bytes)" }
exit 1
}
}
Copy-Item "$releaseDir/psmux.exe" "$zipName/psmux.exe" -ErrorAction Stop
Copy-Item "$releaseDir/pmux.exe" "$zipName/pmux.exe" -ErrorAction Stop
Copy-Item "$releaseDir/tmux.exe" "$zipName/tmux.exe" -ErrorAction Stop
Copy-Item "README.md" "$zipName/" -ErrorAction Stop
Copy-Item "LICENSE" "$zipName/" -ErrorAction Stop
Compress-Archive -Path "$zipName/*" -DestinationPath "$zipName.zip"
# Verify zip contents
$entries = [System.IO.Compression.ZipFile]::OpenRead("$zipName.zip").Entries.Name
foreach ($exe in @("psmux.exe", "pmux.exe", "tmux.exe")) {
if ($exe -notin $entries) {
Write-Error "FATAL: '$exe' missing from zip archive!"
exit 1
}
}
Write-Output "Zip contains: $($entries -join ', ')"
- name: Install NSIS
shell: pwsh
run: |
choco install nsis -y --no-progress
# Install EnVar plugin for PATH manipulation
$nsisDir = "C:\Program Files (x86)\NSIS"
$pluginUrl = "https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip"
$pluginZip = "$env:TEMP\EnVar_plugin.zip"
Invoke-WebRequest -Uri $pluginUrl -OutFile $pluginZip
Expand-Archive -Path $pluginZip -DestinationPath "$env:TEMP\EnVar" -Force
Copy-Item "$env:TEMP\EnVar\Plugins\x86-ansi\*" "$nsisDir\Plugins\x86-ansi\" -Force
Copy-Item "$env:TEMP\EnVar\Plugins\x86-unicode\*" "$nsisDir\Plugins\x86-unicode\" -Force
- name: Build NSIS installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ inputs.tag }}" -replace '^v', ''
$releaseDir = Resolve-Path "target/${{ matrix.target }}/release"
$repoDir = Resolve-Path "."
New-Item -ItemType Directory -Path "target\installer" -Force | Out-Null
& "C:\Program Files (x86)\NSIS\makensis.exe" /NOCD `
/DVERSION=$version `
/DARCH=${{ matrix.nsis_arch }} `
"/DSOURCE_DIR=$releaseDir" `
"/DREPO_DIR=$repoDir" `
"installer\psmux.nsi"
if ($LASTEXITCODE -ne 0) { Write-Error "NSIS build failed"; exit 1 }
$setup = "target\installer\psmux-v${version}-${{ matrix.nsis_arch }}-setup.exe"
if (-not (Test-Path $setup)) { Write-Error "Setup not found: $setup"; exit 1 }
$sizeMB = [math]::Round((Get-Item $setup).Length / 1MB, 2)
Write-Output "NSIS installer: $setup ($sizeMB MB)"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: psmux-${{ matrix.artifact }}
path: |
psmux-*-${{ matrix.artifact }}.zip
target/installer/*-${{ matrix.nsis_arch }}-setup.exe
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
# Find the previous tag
CURRENT_TAG="${{ inputs.tag }}"
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found — generating changelog from all commits"
LOG=$(git log --pretty=format:"- %s (%h)" --no-merges "$CURRENT_TAG")
else
echo "Generating changelog: $PREV_TAG → $CURRENT_TAG"
LOG=$(git log --pretty=format:"- %s (%h)" --no-merges "${PREV_TAG}..${CURRENT_TAG}")
fi
# Write to file to preserve multiline content
echo "$LOG" > /tmp/changelog.md
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Build release body
id: body
run: |
CURRENT_TAG="${{ inputs.tag }}"
PREV_TAG="${{ steps.changelog.outputs.prev_tag }}"
{
echo "## psmux ${CURRENT_TAG}"
echo ""
echo "Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal."
echo ""
if [ -n "$PREV_TAG" ]; then
echo "### Changelog (${PREV_TAG} → ${CURRENT_TAG})"
else
echo "### Changelog"
fi
echo ""
cat /tmp/changelog.md
echo ""
echo ""
echo "### Downloads"
echo ""
echo "**Portable (zip):**"
echo "| Platform | File |"
echo "|----------|------|"
echo "| Windows x64 (Intel/AMD 64-bit) | \`psmux-${CURRENT_TAG}-windows-x64.zip\` |"
echo "| Windows x86 (Intel/AMD 32-bit) | \`psmux-${CURRENT_TAG}-windows-x86.zip\` |"
echo "| Windows ARM64 (Snapdragon/Surface Pro X) | \`psmux-${CURRENT_TAG}-windows-arm64.zip\` |"
echo ""
echo "**Installer (NSIS setup — kills running instances, adds to PATH, warmup):**"
echo "| Platform | File |"
echo "|----------|------|"
VERSION_NO_V="${CURRENT_TAG#v}"
echo "| Windows x64 | \`psmux-v${VERSION_NO_V}-x64-setup.exe\` |"
echo "| Windows x86 | \`psmux-v${VERSION_NO_V}-x86-setup.exe\` |"
echo "| Windows ARM64 | \`psmux-v${VERSION_NO_V}-arm64-setup.exe\` |"
echo ""
echo "### Installation"
echo ""
echo "**Via Scoop (recommended):**"
echo "\`\`\`powershell"
echo "scoop bucket add psmux https://github.com/psmux/scoop-psmux"
echo "scoop install psmux"
echo "\`\`\`"
echo ""
echo "**Via Chocolatey:**"
echo "\`\`\`powershell"
echo "choco install psmux"
echo "\`\`\`"
echo ""
echo "**Via WinGet:**"
echo "\`\`\`powershell"
echo "winget install marlocarlo.psmux"
echo "\`\`\`"
echo ""
echo "**Via Cargo:**"
echo "\`\`\`powershell"
echo "cargo install psmux"
echo "\`\`\`"
echo ""
echo "**Via PowerShell:**"
echo "\`\`\`powershell"
echo "irm https://raw.githubusercontent.com/psmux/psmux/master/scripts/install.ps1 | iex"
echo "\`\`\`"
echo ""
echo "**Manual Installation:**"
echo "1. Download the zip matching your architecture"
echo "2. Extract to a folder in your PATH"
echo "3. \`psmux\`, \`pmux\`, and \`tmux\` commands will all be available"
echo ""
echo "### What's Included"
echo "- \`psmux.exe\` - Main executable"
echo "- \`pmux.exe\` - Alias"
echo "- \`tmux.exe\` - Alias for tmux compatibility"
} > /tmp/release_body.md
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: List artifacts
run: find . -type f \( -name "*.zip" -o -name "*-setup.exe" \)
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
files: |
psmux-windows-x64/*.zip
psmux-windows-x86/*.zip
psmux-windows-arm64/*.zip
psmux-windows-x64/**/*-setup.exe
psmux-windows-x86/**/*-setup.exe
psmux-windows-arm64/**/*-setup.exe
body_path: /tmp/release_body.md
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crates:
name: Publish to crates.io
needs: release
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Verify crate compiles with crates.io dependencies
run: |
cargo check 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "Crate fails to compile with crates.io dependencies! Fix before publishing."
exit 1
}
shell: pwsh
- name: Publish sub-crates then main crate to crates.io
shell: pwsh
run: |
# Sub-crates must be published first so crates.io can resolve path deps
cargo publish --allow-dirty -p portable-pty-psmux 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Warning "portable-pty-psmux publish failed (may already be published)"
}
Start-Sleep 15
cargo publish --allow-dirty -p vt100-psmux 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Warning "vt100-psmux publish failed (may already be published)"
}
Start-Sleep 15
$out = cargo publish --allow-dirty 2>&1
$out | Write-Output
if ($LASTEXITCODE -ne 0) {
# Treat "already exists" as a warning so re-runs don't break
if ($out -match "already exists") {
Write-Warning "psmux already published at this version -- skipping"
} else {
Write-Error "psmux publish failed!"
exit 1
}
}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
publish-chocolatey:
name: Publish to Chocolatey
needs: release
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Wait for release assets to stabilize on GitHub CDN
shell: pwsh
run: |
Write-Output "Waiting 60s for GitHub release CDN propagation..."
Start-Sleep 60
- name: Extract version from tag
id: version
shell: pwsh
run: |
$tag = "${{ inputs.tag }}"
$ver = $tag -replace '^v', ''
echo "VERSION=$ver" >> $env:GITHUB_OUTPUT
echo "TAG=$tag" >> $env:GITHUB_OUTPUT
Write-Output "Version: $ver Tag: $tag"
- name: Download x64 release zip from GitHub Release
shell: pwsh
run: |
$tag = "${{ steps.version.outputs.TAG }}"
$zipUrl = "https://github.com/${{ github.repository }}/releases/download/$tag/psmux-$tag-windows-x64.zip"
Write-Output "Downloading $zipUrl"
$ok = $false
for ($i = 1; $i -le 5; $i++) {
try {
Invoke-WebRequest -Uri $zipUrl -OutFile "psmux-release.zip" -UseBasicParsing -ErrorAction Stop
$ok = $true; break
} catch {
if ($i -eq 5) { throw }
Write-Output "Retry $i..."
Start-Sleep ($i * 10)
}
}
- name: Compute SHA256 checksum
id: checksum
shell: pwsh
run: |
$hash = (Get-FileHash "psmux-release.zip" -Algorithm SHA256).Hash
echo "SHA256=$hash" >> $env:GITHUB_OUTPUT
Write-Output "SHA256: $hash"
- name: Build and push Chocolatey package
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$ver = "${{ steps.version.outputs.VERSION }}"
$tag = "${{ steps.version.outputs.TAG }}"
$hash = "${{ steps.checksum.outputs.SHA256 }}"
$url = "https://github.com/${{ github.repository }}/releases/download/$tag/psmux-$tag-windows-x64.zip"
$utf8 = New-Object System.Text.UTF8Encoding $false
New-Item -ItemType Directory -Path "choco-pkg/tools" -Force | Out-Null
# --- chocolateyinstall.ps1 ---
$install = @(
'$ErrorActionPreference = ''Stop'''
''
'$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"'
('$url64 = ''{0}''' -f $url)
''
'$packageArgs = @{'
' packageName = $env:ChocolateyPackageName'
' unzipLocation = $toolsDir'
' url64bit = $url64'
(' checksum64 = ''{0}''' -f $hash)
' checksumType64 = ''sha256'''
'}'
''
'Install-ChocolateyZipPackage @packageArgs'
''
'$psmuxPath = Join-Path $toolsDir "psmux.exe"'
'$pmuxPath = Join-Path $toolsDir "pmux.exe"'
'$tmuxPath = Join-Path $toolsDir "tmux.exe"'
''
'Install-BinFile -Name "psmux" -Path $psmuxPath'
'Install-BinFile -Name "pmux" -Path $pmuxPath'
'Install-BinFile -Name "tmux" -Path $tmuxPath'
) -join "`n"
[IO.File]::WriteAllText("$PWD/choco-pkg/tools/chocolateyinstall.ps1", $install, $utf8)
# --- chocolateyuninstall.ps1 ---
$uninstall = @(
'Uninstall-BinFile -Name "psmux"'
'Uninstall-BinFile -Name "pmux"'
'Uninstall-BinFile -Name "tmux"'
) -join "`n"
[IO.File]::WriteAllText("$PWD/choco-pkg/tools/chocolateyuninstall.ps1", $uninstall, $utf8)
# --- nuspec ---
$nuspec = @(
''
''
' '
' psmux '
(' {0} ' -f $ver)
' psmux - Terminal Multiplexer for Windows '
' Josh '
' Josh '
' https://github.com/psmux/psmux/blob/master/LICENSE '
' https://github.com/psmux/psmux '
' false '
' Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal. Includes psmux, pmux, and tmux commands. '
' Terminal multiplexer for Windows (tmux alternative) '
' https://github.com/psmux/psmux/releases '
' terminal multiplexer tmux powershell cli windows psmux pmux '
' https://github.com/psmux/psmux '
' https://github.com/psmux/psmux#readme '
' https://github.com/psmux/psmux/issues '
' '
' '
' '
' '
' '
) -join "`n"
[IO.File]::WriteAllText("$PWD/choco-pkg/psmux.nuspec", $nuspec, $utf8)
# --- Pack and push ---
cd choco-pkg
choco pack psmux.nuspec
$nupkg = (Get-ChildItem *.nupkg)[0].Name
Write-Output "Packed: $nupkg"
choco push $nupkg --source https://push.chocolatey.org/ --api-key ${{ secrets.CHOCOLATEY_API_KEY }}
Write-Output "Successfully pushed $nupkg to Chocolatey"
publish-scoop:
name: Publish to Scoop Bucket
needs: release
runs-on: ubuntu-latest
steps:
- name: Wait for release assets to stabilize on GitHub CDN
run: |
echo "Waiting 60s for GitHub release CDN propagation..."
sleep 60
- name: Extract version from tag
id: version
shell: bash
run: |
tag="${{ inputs.tag }}"
ver="${tag#v}"
echo "VERSION=$ver" >> $GITHUB_OUTPUT
echo "TAG=$tag" >> $GITHUB_OUTPUT
- name: Download release zips and compute SHA256
id: hashes
shell: bash
run: |
tag="${{ inputs.tag }}"
repo="${{ github.repository }}"
base="https://github.com/$repo/releases/download/$tag"
for arch in x64 x86 arm64; do
url="$base/psmux-$tag-windows-$arch.zip"
echo "Downloading $url"
for i in 1 2 3 4 5; do
if curl -sL -o "installer-$arch.zip" "$url"; then break; fi
echo "Retry $i..."
sleep $((i * 10))
done
hash=$(sha256sum "installer-$arch.zip" | awk '{print $1}')
upper_arch=$(echo "$arch" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
echo "SHA256_$upper_arch=$hash" >> $GITHUB_OUTPUT
echo " $arch: $hash"
done
- name: Clone scoop bucket repo
shell: bash
run: |
git clone https://x-access-token:${{ secrets.WINGET_PAT }}@github.com/psmux/scoop-psmux.git scoop-bucket
- name: Update scoop manifest
shell: bash
run: |
ver="${{ steps.version.outputs.VERSION }}"
tag="${{ steps.version.outputs.TAG }}"
sha64="${{ steps.hashes.outputs.SHA256_X64 }}"
sha86="${{ steps.hashes.outputs.SHA256_X86 }}"
shaarm="${{ steps.hashes.outputs.SHA256_ARM64 }}"
repo="${{ github.repository }}"
base="https://github.com/$repo/releases/download/$tag"
cat > scoop-bucket/bucket/psmux.json << MANIFEST
{
"version": "$ver",
"description": "Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal",
"homepage": "https://github.com/psmux/psmux",
"license": "MIT",
"architecture": {
"64bit": {
"url": "$base/psmux-$tag-windows-x64.zip",
"hash": "$sha64"
},
"32bit": {
"url": "$base/psmux-$tag-windows-x86.zip",
"hash": "$sha86"
},
"arm64": {
"url": "$base/psmux-$tag-windows-arm64.zip",
"hash": "$shaarm"
}
},
"bin": [
"psmux.exe",
"pmux.exe",
"tmux.exe"
],
"checkver": {
"github": "https://github.com/psmux/psmux"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/psmux/psmux/releases/download/v\$version/psmux-v\$version-windows-x64.zip"
},
"32bit": {
"url": "https://github.com/psmux/psmux/releases/download/v\$version/psmux-v\$version-windows-x86.zip"
},
"arm64": {
"url": "https://github.com/psmux/psmux/releases/download/v\$version/psmux-v\$version-windows-arm64.zip"
}
}
}
}
MANIFEST
# Remove leading whitespace from heredoc indentation
cd scoop-bucket
python3 -c "
import json
with open('bucket/psmux.json') as f:
data = json.load(f)
with open('bucket/psmux.json', 'w', newline='\n') as f:
json.dump(data, f, indent=4)
f.write('\n')
"
- name: Push updated manifest to scoop bucket
shell: bash
run: |
cd scoop-bucket
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add bucket/psmux.json
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "Update psmux to ${{ steps.version.outputs.VERSION }}"
git push
publish-winget:
name: Publish to WinGet
needs: release
runs-on: windows-latest
steps:
- name: Wait for release assets to stabilize on GitHub CDN
shell: pwsh
run: |
Write-Output "Waiting 90s for GitHub release CDN propagation..."
Start-Sleep 90
- name: Extract version from tag
id: version
shell: pwsh
run: |
$tag = "${{ inputs.tag }}"
$ver = $tag -replace '^v', ''
echo "VERSION=$ver" >> $env:GITHUB_OUTPUT
echo "TAG=$tag" >> $env:GITHUB_OUTPUT
Write-Output "Version: $ver Tag: $tag"
- name: Download release zips and compute SHA256
id: hashes
shell: pwsh
run: |
$tag = "${{ steps.version.outputs.TAG }}"
$repo = "${{ github.repository }}"
$base = "https://github.com/$repo/releases/download/$tag"
$archs = @(
@{ label = "x64"; artifact = "windows-x64" },
@{ label = "x86"; artifact = "windows-x86" },
@{ label = "arm64"; artifact = "windows-arm64" }
)
foreach ($a in $archs) {
$url = "$base/psmux-$tag-$($a.artifact).zip"
$file = "installer-$($a.label).zip"
Write-Output "Downloading $url ..."
$ok = $false
for ($i = 1; $i -le 5; $i++) {
try {
Invoke-WebRequest -Uri $url -OutFile $file -UseBasicParsing -ErrorAction Stop
$ok = $true; break
} catch {
if ($i -eq 5) { throw }
Start-Sleep ($i * 10)
}
}
$hash = (Get-FileHash $file -Algorithm SHA256).Hash
Write-Output " SHA256 ($($a.label)): $hash"
echo "SHA256_$($a.label.ToUpper())=$hash" >> $env:GITHUB_OUTPUT
}
- name: Download wingetcreate
shell: pwsh
run: |
$wgcPath = "$env:TEMP\wingetcreate.exe"
Invoke-WebRequest -Uri https://aka.ms/wingetcreate/latest -OutFile $wgcPath -UseBasicParsing
Write-Output "WGC_PATH=$wgcPath" >> $env:GITHUB_ENV
Write-Output "Downloaded wingetcreate to $wgcPath"
- name: Create WinGet manifests
shell: pwsh
run: |
$ver = "${{ steps.version.outputs.VERSION }}"
$tag = "${{ steps.version.outputs.TAG }}"
$repo = "${{ github.repository }}"
$base = "https://github.com/$repo/releases/download/$tag"
$pkgId = "marlocarlo.psmux"
$outDir = "winget-manifests"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$sha64 = "${{ steps.hashes.outputs.SHA256_X64 }}"
$sha86 = "${{ steps.hashes.outputs.SHA256_X86 }}"
$shaArm = "${{ steps.hashes.outputs.SHA256_ARM64 }}"
$url64 = "$base/psmux-$tag-windows-x64.zip"
$url86 = "$base/psmux-$tag-windows-x86.zip"
$urlArm = "$base/psmux-$tag-windows-arm64.zip"
# ---- Version manifest ----
@"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.1.9.0.schema.json
PackageIdentifier: $pkgId
PackageVersion: $ver
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.9.0
"@ -replace '(?m)^\s{10}','' | Set-Content "$outDir/$pkgId.yaml" -Encoding utf8NoBOM
# ---- Installer manifest ----
@"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.1.9.0.schema.json
PackageIdentifier: $pkgId
PackageVersion: $ver
Platform:
- Windows.Desktop
MinimumOSVersion: 10.0.0.0
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: psmux.exe
PortableCommandAlias: psmux
- RelativeFilePath: pmux.exe
PortableCommandAlias: pmux
- RelativeFilePath: tmux.exe
PortableCommandAlias: tmux
Installers:
- Architecture: x64
InstallerUrl: $url64
InstallerSha256: $sha64
- Architecture: x86
InstallerUrl: $url86
InstallerSha256: $sha86
- Architecture: arm64
InstallerUrl: $urlArm
InstallerSha256: $shaArm
ManifestType: installer
ManifestVersion: 1.9.0
"@ -replace '(?m)^\s{10}','' | Set-Content "$outDir/$pkgId.installer.yaml" -Encoding utf8NoBOM
# ---- Default locale manifest ----
@"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.1.9.0.schema.json
PackageIdentifier: $pkgId
PackageVersion: $ver
PackageLocale: en-US
Publisher: Josh
PublisherUrl: https://github.com/psmux
PublisherSupportUrl: https://github.com/psmux/psmux/issues
PackageName: psmux
PackageUrl: https://github.com/psmux/psmux
License: MIT
LicenseUrl: https://github.com/psmux/psmux/blob/master/LICENSE
Copyright: Copyright (c) Josh
ShortDescription: Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal
Description: |-
psmux is a terminal multiplexer for Windows, bringing tmux-style split panes,
multiple windows, sessions, copy mode, mouse support, and a familiar keybinding
model to PowerShell and Windows Terminal. Ships with psmux, pmux, and tmux
aliases for drop-in tmux compatibility. No WSL, no Cygwin — pure Windows.
Moniker: psmux
Tags:
- terminal
- multiplexer
- tmux
- powershell
- windows
- cli
- pane
- session
- pmux
ReleaseNotesUrl: https://github.com/psmux/psmux/releases/tag/$tag
ManifestType: defaultLocale
ManifestVersion: 1.9.0
"@ -replace '(?m)^\s{10}','' | Set-Content "$outDir/$pkgId.locale.en-US.yaml" -Encoding utf8NoBOM
Write-Output "Manifests created:"
Get-ChildItem $outDir | ForEach-Object {
Write-Output " $($_.Name)"
Get-Content $_.FullName | ForEach-Object { Write-Output " $_" }
Write-Output ""
}
- name: Submit manifests to WinGet (PR to microsoft/winget-pkgs)
shell: pwsh
run: |
# submit validates manifests internally before creating the PR
& $env:WGC_PATH submit "winget-manifests" --token "${{ secrets.WINGET_PAT }}" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "wingetcreate submit failed (exit $LASTEXITCODE)."
exit 1
}
Write-Output "PR submitted to microsoft/winget-pkgs!"
Write-Output "Track at: https://github.com/microsoft/winget-pkgs/pulls?q=is%3Apr+marlocarlo.psmux"
================================================
FILE: .gitignore
================================================
# Build artifacts
target/
# Backup and temp files
*.rs.bk
*.swp
*.tmp
~*
# IDE/editor
.vscode/
.idea/
*.code-workspace
# Trae workspace
.trae/
# Note: Cargo.lock is committed (recommended for binaries)
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Binary and executable files
*.exe
*.dll
*.so
*.dylib
*.a
*.lib
*.o
*.obj
*.pdb
# Large media files
*.mp4
*.avi
*.mov
*.mkv
*.mp3
*.wav
*.flac
*.zip
*.tar
*.tar.gz
*.rar
*.7z
*.iso
*.dmg
# Log files
*.log
# Debug/temp output files
stderr.txt
stdout.txt
temp_cmd.txt
# Test/bugfix result logs (never commit these)
*bugfix*.txt
*bug_fix*.txt
*test_result*.txt
*test_bugfix*.txt
*fix_results*.txt
*_results.txt
*test_*.txt
*tmp_*.txt
# Database files
*.db
*.sqlite
*.sqlite3
# Environment and secrets
.env
.env.local
*.key
*.pem
psmux-windows-x86_64/
psmux-windows-x86_64.zip
# Release artifacts (generated by CI/CD, not source)
release/
*.nupkg
================================================
FILE: Cargo.toml
================================================
[package]
name = "psmux"
version = "3.3.4"
edition = "2021"
description = "Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal"
license = "MIT"
repository = "https://github.com/psmux/psmux"
readme = "README.md"
keywords = ["terminal", "multiplexer", "tmux", "windows", "powershell"]
categories = ["command-line-utilities"]
[workspace]
members = [".", "crates/portable-pty-psmux", "crates/vt100-psmux"]
exclude = [
"target/",
"release/",
"tests/",
"tests-rs/",
"packages/",
"psmux-windows-x86_64/",
".github/",
"scripts/",
"scoop/",
"examples/",
"*.txt",
]
# Disable auto-discovery of integration tests from tests/ directory
[[test]]
name = "test_vt100_mouse"
path = "tests-rs/test_vt100_mouse.rs"
[[test]]
name = "test_issue155_sgr_attrs"
path = "tests-rs/test_issue155_sgr_attrs.rs"
[[test]]
name = "test_issue155_rendering"
path = "tests-rs/test_issue155_rendering.rs"
[[test]]
name = "test_issue201_rename_dialog"
path = "tests-rs/test_issue201_rename_dialog.rs"
[[test]]
name = "test_issue269_osc94_dropped"
path = "tests-rs/test_issue269_osc94_dropped.rs"
[[test]]
name = "test_h1_osc52_clipboard_capture"
path = "tests-rs/test_h1_osc52_clipboard_capture.rs"
[[test]]
name = "test_h1_osc52_end_to_end"
path = "tests-rs/test_h1_osc52_end_to_end.rs"
# Windows only
[target.'cfg(not(windows))'.dependencies]
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_DataExchange",
] }
[[bin]]
name = "psmux"
path = "src/main.rs"
[[bin]]
name = "pmux"
path = "src/main.rs"
[[bin]]
name = "tmux"
path = "src/main.rs"
[dependencies]
ratatui = "0.30"
crossterm = "0.29"
portable-pty = { version = "0.9.3", path = "crates/portable-pty-psmux", package = "portable-pty-psmux" }
which = "8"
chrono = "0.4"
vt100 = { version = "0.16.6", path = "crates/vt100-psmux", package = "vt100-psmux" }
unicode-width = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex = "1"
glob = "0.3"
anyhow = "1"
base64 = "0.22"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = "symbols"
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Josh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
```
╔═══════════════════════════════════════════════════════════╗
║ ██████╗ ███████╗███╗ ███╗██╗ ██╗██╗ ██╗ ║
║ ██╔══██╗██╔════╝████╗ ████║██║ ██║╚██╗██╔╝ ║
║ ██████╔╝███████╗██╔████╔██║██║ ██║ ╚███╔╝ ║
║ ██╔═══╝ ╚════██║██║╚██╔╝██║██║ ██║ ██╔██╗ ║
║ ██║ ███████║██║ ╚═╝ ██║╚██████╔╝██╔╝ ██╗ ║
║ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ║
║ Born in PowerShell. Made in Rust. 🦀 ║
║ Terminal Multiplexer for Windows ║
╚═══════════════════════════════════════════════════════════╝
```
The native Windows tmux. Born in PowerShell, made in Rust.
Full mouse support · tmux themes · tmux config · 83 commands · blazing fast
Install ·
Usage ·
Claude Code ·
Features ·
Compatibility ·
Performance ·
Plugins ·
Keys ·
Scripting ·
Config ·
Mouse/SSH ·
FAQ ·
Related Projects
---
# psmux
**The real tmux for Windows.** Not a port, not a wrapper, not a workaround.
psmux is a **native Windows terminal multiplexer** built from the ground up in Rust. It uses Windows ConPTY directly, speaks the tmux command language, reads your `.tmux.conf`, and supports tmux themes. All without WSL, Cygwin, or MSYS2.
> 💡 **Tip:** psmux ships with `tmux` and `pmux` aliases. Just type `tmux` and it works!
👀 On Windows 👇

## Installation
### Using WinGet
```powershell
winget install psmux
```
### Using Cargo
```powershell
cargo install psmux
```
This installs `psmux`, `pmux`, and `tmux` binaries to your Cargo bin directory.
### Using Scoop
```powershell
scoop bucket add psmux https://github.com/psmux/scoop-psmux
scoop install psmux
```
### Using Chocolatey
```powershell
choco install psmux
```
### From GitHub Releases
Download the latest `.zip` from [GitHub Releases](https://github.com/psmux/psmux/releases) and add to your PATH.
### From Source
```powershell
git clone https://github.com/psmux/psmux.git
cd psmux
cargo build --release
```
Built binaries:
```text
target\release\psmux.exe
target\release\pmux.exe
target\release\tmux.exe
```
### Docker (build environment)
A ready-made Windows container with Rust + MSVC + SSH for building psmux:
```powershell
cd docker
docker build -t psmux-dev .
docker run -d --name psmux-dev -p 127.0.0.1:2222:22 -e ADMIN_PASSWORD=YourPass123! psmux-dev
ssh ContainerAdministrator@localhost -p 2222
```
See [docker/README.md](docker/README.md) for full details.
### Requirements
- Windows 10 or Windows 11
- **PowerShell 7+** (recommended) or cmd.exe
- Download PowerShell: `winget install --id Microsoft.PowerShell`
- Or visit: https://aka.ms/powershell
## Why psmux?
If you've used tmux on Linux/macOS and wished you had something like it on Windows, **this is it**. Split panes, multiple windows, session persistence, full mouse support, tmux themes, 83 commands, 140+ format variables, 53 vim copy-mode keys. Your existing `.tmux.conf` works. Full details: **[docs/features.md](docs/features.md)** · **[docs/compatibility.md](docs/compatibility.md)**
## Usage
Use `psmux`, `pmux`, or `tmux` — they're identical:
```powershell
psmux # Start a new session
psmux new-session -s work # Named session
psmux ls # List sessions
psmux attach -t work # Attach to a session
psmux --help # Show help
```
## Claude Code Agent Teams
psmux has first-class support for Claude Code agent teams. When Claude Code runs inside a psmux session, teammate agents automatically spawn in separate tmux panes instead of running in-process.
```powershell
psmux new-session -s work # Start a psmux session
claude # Run Claude Code — agent teams just work
```
No extra configuration needed. Full guide: **[docs/claude-code.md](docs/claude-code.md)**
## Documentation
| Topic | Description |
|-------|-------------|
| **[Features](docs/features.md)** | Full feature list — mouse, copy mode, layouts, format engine |
| **[Compatibility](docs/compatibility.md)** | tmux command/config compatibility matrix |
| **[Performance](docs/performance.md)** | Benchmarks and optimization details |
| **[Key Bindings](docs/keybindings.md)** | Default keys and customization |
| **[Scripting](docs/scripting.md)** | 83 commands, hooks, targets, pipe-pane |
| **[Configuration](docs/configuration.md)** | Config files, options, environment variables |
| **[Plugins & Themes](docs/plugins.md)** | Plugin ecosystem — Catppuccin, Dracula, Nord, and more |
| **[Mouse Over SSH](docs/mouse-ssh.md)** | SSH mouse support and Windows version requirements |
| **[Claude Code](docs/claude-code.md)** | Agent teams integration guide |
| **[FAQ](docs/faq.md)** | Common questions and answers |
## Related Projects
pstop
htop for Windows — real-time system monitor with per-core CPU bars, tree view, 7 color schemes
cargo install pstop
psnet
Real-time TUI network monitor — live speed graphs, connections, traffic log, packet sniffer
cargo install psnet
Tmux Plugin Panel
TUI plugin & theme manager for tmux and psmux — browse, install, update from your terminal
cargo install tmuxpanel
OMP Manager
Oh My Posh setup wizard — browse 100+ themes, install fonts, configure shells automatically
cargo install omp-manager
## License
MIT
## Contributing
Contributions welcome — bug reports, PRs, docs, and test scripts via [GitHub Issues](https://github.com/psmux/psmux/issues).
If psmux helps your Windows workflow, consider giving it a ⭐ on GitHub!
## Star History
[](https://www.star-history.com/?repos=psmux%2Fpsmux&type=date&legend=top-left)
---
Made with ❤️ for PowerShell using Rust 🦀
================================================
FILE: choco-pkg/psmux.nuspec
================================================
psmux
3.3.3
psmux - Terminal Multiplexer for Windows
Josh
Josh
https://github.com/psmux/psmux/blob/master/LICENSE
https://github.com/psmux/psmux
false
Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal. Includes psmux, pmux, and tmux commands.
Terminal multiplexer for Windows (tmux alternative)
https://github.com/psmux/psmux/releases
terminal multiplexer tmux powershell cli windows psmux pmux
https://github.com/psmux/psmux
https://github.com/psmux/psmux#readme
https://github.com/psmux/psmux/issues
================================================
FILE: choco-pkg/tools/chocolateyinstall.ps1
================================================
$ErrorActionPreference = 'Stop'
$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
$url64 = 'https://github.com/psmux/psmux/releases/download/v3.3.3/psmux-v3.3.3-windows-x64.zip'
$packageArgs = @{
packageName = $env:ChocolateyPackageName
unzipLocation = $toolsDir
url64bit = $url64
checksum64 = 'E6FE103A776ED453647F82254445CBD4BA851E1A14BCBB959FDF858DE16CE5DD'
checksumType64 = 'sha256'
}
Install-ChocolateyZipPackage @packageArgs
$psmuxPath = Join-Path $toolsDir "psmux.exe"
$pmuxPath = Join-Path $toolsDir "pmux.exe"
$tmuxPath = Join-Path $toolsDir "tmux.exe"
Install-BinFile -Name "psmux" -Path $psmuxPath
Install-BinFile -Name "pmux" -Path $pmuxPath
Install-BinFile -Name "tmux" -Path $tmuxPath
================================================
FILE: choco-pkg/tools/chocolateyuninstall.ps1
================================================
Uninstall-BinFile -Name "psmux"
Uninstall-BinFile -Name "pmux"
Uninstall-BinFile -Name "tmux"
================================================
FILE: crates/portable-pty-psmux/.cargo-ok
================================================
{"v":1}
================================================
FILE: crates/portable-pty-psmux/.cargo_vcs_info.json
================================================
{
"git": {
"sha1": "d389cf717cdb7702c9a732d1d9bc0b6f08603b4a"
},
"path_in_vcs": ""
}
================================================
FILE: crates/portable-pty-psmux/Cargo.toml
================================================
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "portable-pty-psmux"
version = "0.9.3"
authors = ["Wez Furlong"]
build = false
include = [
"src/**/*",
"LICENSE.md",
"README.md",
"Cargo.toml",
"examples/**/*",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Cross platform pty interface (psmux fork with ConPTY PASSTHROUGH_MODE + WIN32_INPUT_MODE + RESIZE_QUIRK patches)"
documentation = "https://docs.rs/portable-pty"
readme = false
license = "MIT"
repository = "https://github.com/psmux/portable-pty-patched"
resolver = "2"
[features]
default = []
serde_support = [
"serde",
"serde_derive",
]
[lib]
name = "portable_pty"
path = "src/lib.rs"
[[example]]
name = "bash"
path = "examples/bash.rs"
[[example]]
name = "narrow"
path = "examples/narrow.rs"
[[example]]
name = "whoami"
path = "examples/whoami.rs"
[[example]]
name = "whoami_async"
path = "examples/whoami_async.rs"
[dependencies.anyhow]
version = "1.0"
[dependencies.downcast-rs]
version = "2.0"
[dependencies.filedescriptor]
version = "0.8.3"
[dependencies.libc]
version = "0.2"
[dependencies.log]
version = "0.4"
[dependencies.nix]
version = "0.31"
features = [
"term",
"fs",
]
[dependencies.serde]
version = "1.0"
optional = true
[dependencies.serde_derive]
version = "1.0"
optional = true
[dependencies.serial2]
version = "0.2"
[dependencies.shell-words]
version = "1.1"
[dev-dependencies.futures]
version = "0.3"
[dev-dependencies.smol]
version = "2.0"
[target."cfg(windows)".dependencies.lazy_static]
version = "1.4"
[target."cfg(windows)".dependencies.shared_library]
version = "0.1"
[target."cfg(windows)".dependencies.winapi]
version = "0.3"
features = [
"winuser",
"consoleapi",
"handleapi",
"fileapi",
"namedpipeapi",
"synchapi",
"libloaderapi",
"winnt",
]
[target."cfg(windows)".dependencies.winreg]
version = "0.56"
================================================
FILE: crates/portable-pty-psmux/Cargo.toml.orig
================================================
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "portable-pty-psmux"
version = "0.9.1"
authors = ["Wez Furlong"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Cross platform pty interface (psmux fork with ConPTY PASSTHROUGH_MODE + WIN32_INPUT_MODE + RESIZE_QUIRK patches)"
documentation = "https://docs.rs/portable-pty"
readme = false
license = "MIT"
repository = "https://github.com/marlocarlo/portable-pty-patched"
include = ["src/**/*", "LICENSE.md", "README.md", "Cargo.toml", "examples/**/*"]
resolver = "2"
[lib]
name = "portable_pty"
path = "src/lib.rs"
[[example]]
name = "bash"
path = "examples/bash.rs"
[[example]]
name = "narrow"
path = "examples/narrow.rs"
[[example]]
name = "whoami"
path = "examples/whoami.rs"
[[example]]
name = "whoami_async"
path = "examples/whoami_async.rs"
[dependencies.anyhow]
version = "1.0"
[dependencies.downcast-rs]
version = "1.0"
[dependencies.filedescriptor]
version = "0.8.3"
[dependencies.libc]
version = "0.2"
[dependencies.log]
version = "0.4"
[dependencies.nix]
version = "0.28"
features = [
"term",
"fs",
]
[dependencies.serde]
version = "1.0"
optional = true
[dependencies.serde_derive]
version = "1.0"
optional = true
[dependencies.serial2]
version = "0.2"
[dependencies.shell-words]
version = "1.1"
[dev-dependencies.futures]
version = "0.3"
[dev-dependencies.smol]
version = "2.0"
[features]
default = []
serde_support = [
"serde",
"serde_derive",
]
[target."cfg(windows)".dependencies.bitflags]
version = "1.3"
[target."cfg(windows)".dependencies.lazy_static]
version = "1.4"
[target."cfg(windows)".dependencies.shared_library]
version = "0.1"
[target."cfg(windows)".dependencies.winapi]
version = "0.3"
features = [
"winuser",
"consoleapi",
"handleapi",
"fileapi",
"namedpipeapi",
"synchapi",
"libloaderapi",
"winnt",
]
[target."cfg(windows)".dependencies.winreg]
version = "0.10"
================================================
FILE: crates/portable-pty-psmux/LICENSE.md
================================================
MIT License
Copyright (c) 2018 Wez Furlong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: crates/portable-pty-psmux/README.md
================================================
# portable-pty-patched
Patched version of [portable-pty](https://crates.io/crates/portable-pty) v0.9.0 (originally from [wez/wezterm](https://github.com/wez/wezterm)) with ConPTY flag support required by [psmux](https://github.com/psmux/psmux).
## Why this exists
`portable-pty` is not published as a standalone repo — it lives inside the wezterm monorepo, making a proper GitHub fork impractical (we'd be forking an entire terminal emulator project just for one file change).
The upstream crate does not pass modern ConPTY creation flags that psmux needs for correct terminal behavior on Windows 10/11.
## Patches (`src/win/psuedocon.rs`)
### New ConPTY flags
- **`PSEUDOCONSOLE_RESIZE_QUIRK`** (0x2) — fixes resize artifacts
- **`PSEUDOCONSOLE_WIN32_INPUT_MODE`** (0x4) — enables Win32 input mode for proper key handling
- **`PSEUDOCONSOLE_PASSTHROUGH_MODE`** (0x8) — relays VT sequences directly from child processes (Windows 11 22H2+ only), enabling cursor shape forwarding, DECSCUSR, etc.
### Build detection (`supports_passthrough_mode()`)
Uses `RtlGetVersion` to detect Windows build >= 22621 (Windows 11 22H2). On older builds, passthrough mode is skipped to avoid broken ConPTY output.
### Two-tier `PsuedoCon::new()`
1. Attempts `CreatePseudoConsole` with all flags including `PASSTHROUGH_MODE` on supported builds
2. Falls back to base flags (without passthrough) if the call fails or on older Windows
### Cargo.toml
Added `libloaderapi` and `winnt` features to `winapi` dependency for `GetModuleHandleW`/`GetProcAddress`/`RtlGetVersion`.
## Usage
In your `Cargo.toml`:
```toml
portable-pty = { git = "https://github.com/psmux/portable-pty-patched.git", branch = "main" }
```
## Keeping up to date
This is **not** a GitHub fork (upstream lives inside wezterm monorepo). To sync with a new upstream release:
1. Download the new version from [crates.io](https://crates.io/crates/portable-pty)
2. Re-apply the patches to `src/win/psuedocon.rs` and `Cargo.toml`
================================================
FILE: crates/portable-pty-psmux/examples/bash.rs
================================================
//! This example demonstrates how to spawn a Bash shell using the `portable_pty` crate.
//! based on pty/examples/whoami.rs
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::sync::mpsc::channel;
use std::thread;
fn main() {
let pty_system = NativePtySystem::default();
// Open the PTY with specified size.
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// Set up the command to launch Bash.
let cmd = CommandBuilder::new("bash");
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// Set up channels for reading and writing.
let (tx, rx) = channel::();
let mut reader = pair.master.try_clone_reader().unwrap();
let master_writer = pair.master.take_writer().unwrap();
// Thread to read from the PTY and send data to the main thread.
thread::spawn(move || {
let mut buffer = [0u8; 1024];
loop {
match reader.read(&mut buffer) {
Ok(0) => break, // EOF
Ok(n) => {
let output = String::from_utf8_lossy(&buffer[..n]);
println!("{}", output); // Print to stdout for visibility.
}
Err(e) => {
eprintln!("Error reading from PTY: {}", e);
break;
}
}
}
});
// Thread to write input into the PTY.
let tx_writer = thread::spawn(move || {
handle_input_stream(rx, master_writer);
});
println!("You can now type commands for Bash (type 'exit' to quit):");
// Main thread sends user input to the writer thread.
loop {
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim() == "exit" {
break;
}
tx.send(input).unwrap();
}
drop(tx);
tx_writer.join().unwrap();
println!("Waiting for Bash to exit...");
let status = child.wait().unwrap();
println!("Bash exited with status: {:?}", status);
}
fn handle_input_stream(rx: std::sync::mpsc::Receiver, mut writer: Box) {
for input in rx.iter() {
if writer.write_all(input.as_bytes()).is_err() {
eprintln!("Error writing to PTY");
break;
}
}
}
================================================
FILE: crates/portable-pty-psmux/examples/narrow.rs
================================================
//! Runs a command with a fixed terminal size.
//! This is used by wezterm's doc building automation to keep
//! the --help output within a reasonable width
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::sync::mpsc::channel;
fn main() {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut args = std::env::args_os().skip(1);
let mut cmd = CommandBuilder::new(args.next().unwrap());
cmd.args(args);
let mut child = pair.slave.spawn_command(cmd).unwrap();
// Release any handles owned by the slave: we don't need it now
// that we've spawned the child.
drop(pair.slave);
// Read the output in another thread.
// This is important because it is easy to encounter a situation
// where read/write buffers fill and block either your process
// or the spawned process.
let (tx, rx) = channel();
let mut reader = pair.master.try_clone_reader().unwrap();
std::thread::spawn(move || {
// Consume the output from the child
let mut s = String::new();
reader.read_to_string(&mut s).unwrap();
tx.send(s).unwrap();
});
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let mut writer = pair.master.take_writer().unwrap();
if cfg!(target_os = "macos") {
// macOS quirk: the child and reader must be started and
// allowed a brief grace period to run before we allow
// the writer to drop. Otherwise, the data we send to
// the kernel to trigger EOF is interleaved with the
// data read by the reader! WTF!?
// This appears to be a race condition for very short
// lived processes on macOS.
// I'd love to find a more deterministic solution to
// this than sleeping.
std::thread::sleep(std::time::Duration::from_millis(20));
}
// This example doesn't need to write anything, but if you
// want to send data to the child, you'd set `to_write` to
// that data and do it like this:
let to_write = "";
if !to_write.is_empty() {
// To avoid deadlock, wrt. reading and waiting, we send
// data to the stdin of the child in a different thread.
std::thread::spawn(move || {
writer.write_all(to_write.as_bytes()).unwrap();
});
}
}
// Wait for the child to complete
eprintln!("child status: {:?}", child.wait().unwrap());
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
// Now wait for the output to be read by our reader thread
let output = rx.recv().unwrap();
let output = output.replace("\r\n", "\n");
print!("{output}");
}
================================================
FILE: crates/portable-pty-psmux/examples/whoami.rs
================================================
//! This is a conceptually simple example that spawns the `whoami` program
//! to print your username. It is made more complex because there are multiple
//! pipes involved and it is easy to get blocked/deadlocked if care and attention
//! is not paid to those pipes!
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::sync::mpsc::channel;
fn main() {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let cmd = CommandBuilder::new("whoami");
let mut child = pair.slave.spawn_command(cmd).unwrap();
// Release any handles owned by the slave: we don't need it now
// that we've spawned the child.
drop(pair.slave);
// Read the output in another thread.
// This is important because it is easy to encounter a situation
// where read/write buffers fill and block either your process
// or the spawned process.
let (tx, rx) = channel();
let mut reader = pair.master.try_clone_reader().unwrap();
std::thread::spawn(move || {
// Consume the output from the child
let mut s = String::new();
reader.read_to_string(&mut s).unwrap();
tx.send(s).unwrap();
});
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let mut writer = pair.master.take_writer().unwrap();
if cfg!(target_os = "macos") {
// macOS quirk: the child and reader must be started and
// allowed a brief grace period to run before we allow
// the writer to drop. Otherwise, the data we send to
// the kernel to trigger EOF is interleaved with the
// data read by the reader! WTF!?
// This appears to be a race condition for very short
// lived processes on macOS.
// I'd love to find a more deterministic solution to
// this than sleeping.
std::thread::sleep(std::time::Duration::from_millis(20));
}
// This example doesn't need to write anything, but if you
// want to send data to the child, you'd set `to_write` to
// that data and do it like this:
let to_write = "";
if !to_write.is_empty() {
// To avoid deadlock, wrt. reading and waiting, we send
// data to the stdin of the child in a different thread.
std::thread::spawn(move || {
writer.write_all(to_write.as_bytes()).unwrap();
});
}
}
// Wait for the child to complete
println!("child status: {:?}", child.wait().unwrap());
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
// Now wait for the output to be read by our reader thread
let output = rx.recv().unwrap();
// We print with escapes escaped because the windows conpty
// implementation synthesizes title change escape sequences
// in the output stream and it can be confusing to see those
// printed out raw in another terminal.
print!("output: ");
for c in output.escape_debug() {
print!("{}", c);
}
}
================================================
FILE: crates/portable-pty-psmux/examples/whoami_async.rs
================================================
use anyhow::anyhow;
use futures::prelude::*;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
// This example shows how to use the `smol` crate to use portable_pty
// in an asynchronous application.
fn main() -> anyhow::Result<()> {
smol::block_on(async {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let cmd = CommandBuilder::new("whoami");
// Move the slave to another thread to block and spawn a
// command.
// Note that this implicitly drops slave and closes out
// file handles which is important to avoid deadlock
// when waiting for the child process!
let slave = pair.slave;
let mut child = smol::unblock(move || slave.spawn_command(cmd)).await?;
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let writer = pair.master.take_writer()?;
// Explicitly generate EOF
drop(writer);
}
println!(
"child status: {:?}",
smol::unblock(move || child
.wait()
.map_err(|e| anyhow!("waiting for child: {}", e)))
.await?
);
let reader = pair.master.try_clone_reader()?;
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
let mut lines = smol::io::BufReader::new(smol::Unblock::new(reader)).lines();
while let Some(line) = lines.next().await {
let line = line.map_err(|e| anyhow!("problem reading line: {}", e))?;
// We print with escapes escaped because the windows conpty
// implementation synthesizes title change escape sequences
// in the output stream and it can be confusing to see those
// printed out raw in another terminal.
print!("output: len={} ", line.len());
for c in line.escape_debug() {
print!("{}", c);
}
println!();
}
Ok(())
})
}
================================================
FILE: crates/portable-pty-psmux/src/cmdbuilder.rs
================================================
#[cfg(unix)]
use anyhow::Context;
#[cfg(feature = "serde_support")]
use serde_derive::*;
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(unix)]
use std::path::Component;
use std::path::Path;
/// Used to deal with Windows having case-insensitive environment variables.
#[derive(Clone, Debug, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
struct EnvEntry {
/// Whether or not this environment variable came from the base environment,
/// as opposed to having been explicitly set by the caller.
is_from_base_env: bool,
/// For case-insensitive platforms, the environment variable key in its preferred casing.
preferred_key: OsString,
/// The environment variable value.
value: OsString,
}
impl EnvEntry {
fn map_key(k: OsString) -> OsString {
if cfg!(windows) {
// Best-effort lowercase transformation of an os string
match k.to_str() {
Some(s) => s.to_lowercase().into(),
None => k,
}
} else {
k
}
}
}
#[cfg(unix)]
fn get_shell() -> String {
use nix::unistd::{access, AccessFlags};
use std::ffi::CStr;
use std::str;
let ent = unsafe { libc::getpwuid(libc::getuid()) };
if !ent.is_null() {
let shell = unsafe { CStr::from_ptr((*ent).pw_shell) };
match shell.to_str().map(str::to_owned) {
Err(err) => {
log::warn!(
"passwd database shell could not be \
represented as utf-8: {err:#}, \
falling back to /bin/sh"
);
}
Ok(shell) => {
if let Err(err) = access(Path::new(&shell), AccessFlags::X_OK) {
log::warn!(
"passwd database shell={shell:?} which is \
not executable ({err:#}), falling back to /bin/sh"
);
} else {
return shell;
}
}
}
}
"/bin/sh".into()
}
fn get_base_env() -> BTreeMap {
let mut env: BTreeMap = std::env::vars_os()
.map(|(key, value)| {
(
EnvEntry::map_key(key.clone()),
EnvEntry {
is_from_base_env: true,
preferred_key: key,
value,
},
)
})
.collect();
#[cfg(unix)]
{
let key = EnvEntry::map_key("SHELL".into());
// Only set the value of SHELL if it isn't already set
if !env.contains_key(&key) {
env.insert(
EnvEntry::map_key("SHELL".into()),
EnvEntry {
is_from_base_env: true,
preferred_key: "SHELL".into(),
value: get_shell().into(),
},
);
}
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
use winapi::um::processenv::ExpandEnvironmentStringsW;
use winreg::enums::{RegType, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::types::FromRegValue;
use winreg::{RegKey, RegValue};
fn reg_value_to_string(value: &RegValue) -> anyhow::Result {
match value.vtype {
RegType::REG_EXPAND_SZ => {
let src = unsafe {
std::slice::from_raw_parts(
value.bytes.as_ptr() as *const u16,
value.bytes.len() / 2,
)
};
let size =
unsafe { ExpandEnvironmentStringsW(src.as_ptr(), std::ptr::null_mut(), 0) };
let mut buf = vec![0u16; size as usize + 1];
unsafe {
ExpandEnvironmentStringsW(src.as_ptr(), buf.as_mut_ptr(), buf.len() as u32)
};
let mut buf = buf.as_slice();
while let Some(0) = buf.last() {
buf = &buf[0..buf.len() - 1];
}
Ok(OsString::from_wide(buf))
}
_ => Ok(OsString::from_reg_value(value)?),
}
}
if let Ok(sys_env) = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("System\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for res in sys_env.enum_values() {
if let Ok((name, value)) = res {
if name.eq_ignore_ascii_case("username") {
continue;
}
if let Ok(value) = reg_value_to_string(&value) {
log::trace!("adding SYS env: {:?} {:?}", name, value);
env.insert(
EnvEntry::map_key(name.clone().into()),
EnvEntry {
is_from_base_env: true,
preferred_key: name.into(),
value,
},
);
}
}
}
}
if let Ok(sys_env) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for res in sys_env.enum_values() {
if let Ok((name, value)) = res {
if let Ok(value) = reg_value_to_string(&value) {
// Merge the system and user paths together
let value = if name.eq_ignore_ascii_case("path") {
match env.get(&EnvEntry::map_key(name.clone().into())) {
Some(entry) => {
let mut result = OsString::new();
result.push(&entry.value);
result.push(";");
result.push(&value);
result
}
None => value,
}
} else {
value
};
log::trace!("adding USER env: {:?} {:?}", name, value);
env.insert(
EnvEntry::map_key(name.clone().into()),
EnvEntry {
is_from_base_env: true,
preferred_key: name.into(),
value,
},
);
}
}
}
}
}
env
}
/// `CommandBuilder` is used to prepare a command to be spawned into a pty.
/// The interface is intentionally similar to that of `std::process::Command`.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct CommandBuilder {
args: Vec,
envs: BTreeMap,
cwd: Option,
#[cfg(unix)]
pub(crate) umask: Option,
controlling_tty: bool,
}
impl CommandBuilder {
/// Create a new builder instance with argv\[0\] set to the specified
/// program.
pub fn new>(program: S) -> Self {
Self {
args: vec![program.as_ref().to_owned()],
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Create a new builder instance from a pre-built argument vector
pub fn from_argv(args: Vec) -> Self {
Self {
args,
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Set whether we should set the pty as the controlling terminal.
/// The default is true, which is usually what you want, but you
/// may need to set this to false if you are crossing container
/// boundaries (eg: flatpak) to workaround issues like:
///
///
pub fn set_controlling_tty(&mut self, controlling_tty: bool) {
self.controlling_tty = controlling_tty;
}
pub fn get_controlling_tty(&self) -> bool {
self.controlling_tty
}
/// Create a new builder instance that will run some idea of a default
/// program. Such a builder will panic if `arg` is called on it.
pub fn new_default_prog() -> Self {
Self {
args: vec![],
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Returns true if this builder was created via `new_default_prog`
pub fn is_default_prog(&self) -> bool {
self.args.is_empty()
}
/// Append an argument to the current command line.
/// Will panic if called on a builder created via `new_default_prog`.
pub fn arg>(&mut self, arg: S) {
if self.is_default_prog() {
panic!("attempted to add args to a default_prog builder");
}
self.args.push(arg.as_ref().to_owned());
}
/// Append a sequence of arguments to the current command line
pub fn args(&mut self, args: I)
where
I: IntoIterator- ,
S: AsRef
,
{
for arg in args {
self.arg(arg);
}
}
pub fn get_argv(&self) -> &Vec {
&self.args
}
pub fn get_argv_mut(&mut self) -> &mut Vec {
&mut self.args
}
/// Override the value of an environmental variable
pub fn env(&mut self, key: K, value: V)
where
K: AsRef,
V: AsRef,
{
let key: OsString = key.as_ref().into();
let value: OsString = value.as_ref().into();
self.envs.insert(
EnvEntry::map_key(key.clone()),
EnvEntry {
is_from_base_env: false,
preferred_key: key,
value: value,
},
);
}
pub fn env_remove(&mut self, key: K)
where
K: AsRef,
{
let key = key.as_ref().into();
self.envs.remove(&EnvEntry::map_key(key));
}
pub fn env_clear(&mut self) {
self.envs.clear();
}
pub fn get_env(&self, key: K) -> Option<&OsStr>
where
K: AsRef,
{
let key = key.as_ref().into();
self.envs.get(&EnvEntry::map_key(key)).map(
|EnvEntry {
is_from_base_env: _,
preferred_key: _,
value,
}| value.as_os_str(),
)
}
pub fn cwd(&mut self, dir: D)
where
D: AsRef,
{
self.cwd = Some(dir.as_ref().to_owned());
}
pub fn clear_cwd(&mut self) {
self.cwd.take();
}
pub fn get_cwd(&self) -> Option<&OsString> {
self.cwd.as_ref()
}
/// Iterate over the configured environment. Only includes environment
/// variables set by the caller via `env`, not variables set in the base
/// environment.
pub fn iter_extra_env_as_str(&self) -> impl Iterator- {
self.envs.values().filter_map(
|EnvEntry {
is_from_base_env,
preferred_key,
value,
}| {
if *is_from_base_env {
None
} else {
let key = preferred_key.to_str()?;
let value = value.to_str()?;
Some((key, value))
}
},
)
}
pub fn iter_full_env_as_str(&self) -> impl Iterator
- {
self.envs.values().filter_map(
|EnvEntry {
preferred_key,
value,
..
}| {
let key = preferred_key.to_str()?;
let value = value.to_str()?;
Some((key, value))
},
)
}
/// Return the configured command and arguments as a single string,
/// quoted per the unix shell conventions.
pub fn as_unix_command_line(&self) -> anyhow::Result
{
let mut strs = vec![];
for arg in &self.args {
let s = arg
.to_str()
.ok_or_else(|| anyhow::anyhow!("argument cannot be represented as utf8"))?;
strs.push(s);
}
Ok(shell_words::join(strs))
}
}
#[cfg(unix)]
impl CommandBuilder {
pub fn umask(&mut self, mask: Option) {
self.umask = mask;
}
fn resolve_path(&self) -> Option<&OsStr> {
self.get_env("PATH")
}
fn search_path(&self, exe: &OsStr, cwd: &OsStr) -> anyhow::Result {
use nix::unistd::{access, AccessFlags};
let exe_path: &Path = exe.as_ref();
if exe_path.is_relative() {
let cwd: &Path = cwd.as_ref();
let mut errors = vec![];
// If the requested executable is explicitly relative to cwd,
// then check only there.
if is_cwd_relative_path(exe_path) {
let abs_path = cwd.join(exe_path);
if abs_path.is_dir() {
anyhow::bail!(
"Unable to spawn {} because it is a directory",
abs_path.display()
);
} else if access(&abs_path, AccessFlags::X_OK).is_ok() {
return Ok(abs_path.into_os_string());
} else if access(&abs_path, AccessFlags::F_OK).is_ok() {
anyhow::bail!(
"Unable to spawn {} because it is not executable",
abs_path.display()
);
}
anyhow::bail!(
"Unable to spawn {} because it does not exist",
abs_path.display()
);
}
if let Some(path) = self.resolve_path() {
for path in std::env::split_paths(&path) {
let candidate = cwd.join(&path).join(&exe);
if candidate.is_dir() {
errors.push(format!("{} exists but is a directory", candidate.display()));
} else if access(&candidate, AccessFlags::X_OK).is_ok() {
return Ok(candidate.into_os_string());
} else if access(&candidate, AccessFlags::F_OK).is_ok() {
errors.push(format!(
"{} exists but is not executable",
candidate.display()
));
}
}
errors.push(format!("No viable candidates found in PATH {path:?}"));
} else {
errors.push("Unable to resolve the PATH".to_string());
}
anyhow::bail!(
"Unable to spawn {} because:\n{}",
exe_path.display(),
errors.join(".\n")
);
} else if exe_path.is_dir() {
anyhow::bail!(
"Unable to spawn {} because it is a directory",
exe_path.display()
);
} else {
if let Err(err) = access(exe_path, AccessFlags::X_OK) {
if access(exe_path, AccessFlags::F_OK).is_ok() {
anyhow::bail!(
"Unable to spawn {} because it is not executable ({err:#})",
exe_path.display()
);
} else {
anyhow::bail!(
"Unable to spawn {} because it doesn't exist on the filesystem ({err:#})",
exe_path.display()
);
}
}
Ok(exe.to_owned())
}
}
/// Convert the CommandBuilder to a `std::process::Command` instance.
pub(crate) fn as_command(&self) -> anyhow::Result {
use std::os::unix::process::CommandExt;
let home = self.get_home_dir()?;
let dir: &OsStr = self
.cwd
.as_ref()
.map(|dir| dir.as_os_str())
.filter(|dir| std::path::Path::new(dir).is_dir())
.unwrap_or(home.as_ref());
let shell = self.get_shell();
let mut cmd = if self.is_default_prog() {
let mut cmd = std::process::Command::new(&shell);
// Run the shell as a login shell by prefixing the shell's
// basename with `-` and setting that as argv0
let basename = shell.rsplit('/').next().unwrap_or(&shell);
cmd.arg0(&format!("-{}", basename));
cmd
} else {
let resolved = self.search_path(&self.args[0], dir)?;
let mut cmd = std::process::Command::new(&resolved);
cmd.arg0(&self.args[0]);
cmd.args(&self.args[1..]);
cmd
};
cmd.current_dir(dir);
cmd.env_clear();
cmd.env("SHELL", shell);
cmd.envs(self.envs.values().map(
|EnvEntry {
is_from_base_env: _,
preferred_key,
value,
}| (preferred_key.as_os_str(), value.as_os_str()),
));
Ok(cmd)
}
/// Determine which shell to run.
/// We take the contents of the $SHELL env var first, then
/// fall back to looking it up from the password database.
pub fn get_shell(&self) -> String {
use nix::unistd::{access, AccessFlags};
if let Some(shell) = self.get_env("SHELL").and_then(OsStr::to_str) {
match access(shell, AccessFlags::X_OK) {
Ok(()) => return shell.into(),
Err(err) => log::warn!(
"$SHELL -> {shell:?} which is \
not executable ({err:#}), falling back to password db lookup"
),
}
}
get_shell().into()
}
fn get_home_dir(&self) -> anyhow::Result {
if let Some(home_dir) = self.get_env("HOME").and_then(OsStr::to_str) {
return Ok(home_dir.into());
}
let ent = unsafe { libc::getpwuid(libc::getuid()) };
if ent.is_null() {
Ok("/".into())
} else {
use std::ffi::CStr;
use std::str;
let home = unsafe { CStr::from_ptr((*ent).pw_dir) };
home.to_str()
.map(str::to_owned)
.context("failed to resolve home dir")
}
}
}
#[cfg(windows)]
impl CommandBuilder {
fn search_path(&self, exe: &OsStr) -> OsString {
if let Some(path) = self.get_env("PATH") {
let extensions = self.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE"));
for path in std::env::split_paths(&path) {
// Check for exactly the user's string in this path dir
let candidate = path.join(&exe);
if candidate.exists() {
return candidate.into_os_string();
}
// otherwise try tacking on some extensions.
// Note that this really replaces the extension in the
// user specified path, so this is potentially wrong.
for ext in std::env::split_paths(&extensions) {
// PATHEXT includes the leading `.`, but `with_extension`
// doesn't want that
let ext = ext.to_str().expect("PATHEXT entries must be utf8");
let path = path.join(&exe).with_extension(&ext[1..]);
if path.exists() {
return path.into_os_string();
}
}
}
}
exe.to_owned()
}
pub(crate) fn current_directory(&self) -> Option> {
let home: Option<&OsStr> = self
.get_env("USERPROFILE")
.filter(|path| Path::new(path).is_dir());
let cwd: Option<&OsStr> = self.cwd.as_deref().filter(|path| Path::new(path).is_dir());
let dir: Option<&OsStr> = cwd.or(home);
dir.map(|dir| {
let mut wide = vec![];
if Path::new(dir).is_relative() {
if let Ok(ccwd) = std::env::current_dir() {
wide.extend(ccwd.join(dir).as_os_str().encode_wide());
} else {
wide.extend(dir.encode_wide());
}
} else {
wide.extend(dir.encode_wide());
}
wide.push(0);
wide
})
}
/// Constructs an environment block for this spawn attempt.
/// Uses the current process environment as the base and then
/// adds/replaces the environment that was specified via the
/// `env` methods.
pub(crate) fn environment_block(&self) -> Vec {
// encode the environment as wide characters
let mut block = vec![];
for EnvEntry {
is_from_base_env: _,
preferred_key,
value,
} in self.envs.values()
{
block.extend(preferred_key.encode_wide());
block.push(b'=' as u16);
block.extend(value.encode_wide());
block.push(0);
}
// and a final terminator for CreateProcessW
block.push(0);
block
}
pub fn get_shell(&self) -> String {
let exe: OsString = self
.get_env("ComSpec")
.unwrap_or(OsStr::new("cmd.exe"))
.into();
exe.into_string()
.unwrap_or_else(|_| "%CompSpec%".to_string())
}
pub(crate) fn cmdline(&self) -> anyhow::Result<(Vec, Vec)> {
let mut cmdline = Vec::::new();
let exe: OsString = if self.is_default_prog() {
self.get_env("ComSpec")
.unwrap_or(OsStr::new("cmd.exe"))
.into()
} else {
self.search_path(&self.args[0])
};
Self::append_quoted(&exe, &mut cmdline);
// Ensure that we nul terminate the module name, otherwise we'll
// ask CreateProcessW to start something random!
let mut exe: Vec = exe.encode_wide().collect();
exe.push(0);
for arg in self.args.iter().skip(1) {
cmdline.push(' ' as u16);
anyhow::ensure!(
!arg.encode_wide().any(|c| c == 0),
"invalid encoding for command line argument {:?}",
arg
);
Self::append_quoted(arg, &mut cmdline);
}
// Ensure that the command line is nul terminated too!
cmdline.push(0);
Ok((exe, cmdline))
}
// Borrowed from https://github.com/hniksic/rust-subprocess/blob/873dfed165173e52907beb87118b2c0c05d8b8a1/src/popen.rs#L1117
// which in turn was translated from ArgvQuote at http://tinyurl.com/zmgtnls
fn append_quoted(arg: &OsStr, cmdline: &mut Vec) {
if !arg.is_empty()
&& !arg.encode_wide().any(|c| {
c == ' ' as u16
|| c == '\t' as u16
|| c == '\n' as u16
|| c == '\x0b' as u16
|| c == '\"' as u16
})
{
cmdline.extend(arg.encode_wide());
return;
}
cmdline.push('"' as u16);
let arg: Vec<_> = arg.encode_wide().collect();
let mut i = 0;
while i < arg.len() {
let mut num_backslashes = 0;
while i < arg.len() && arg[i] == '\\' as u16 {
i += 1;
num_backslashes += 1;
}
if i == arg.len() {
for _ in 0..num_backslashes * 2 {
cmdline.push('\\' as u16);
}
break;
} else if arg[i] == b'"' as u16 {
for _ in 0..num_backslashes * 2 + 1 {
cmdline.push('\\' as u16);
}
cmdline.push(arg[i]);
} else {
for _ in 0..num_backslashes {
cmdline.push('\\' as u16);
}
cmdline.push(arg[i]);
}
i += 1;
}
cmdline.push('"' as u16);
}
}
#[cfg(unix)]
/// Returns true if the path begins with `./` or `../`
fn is_cwd_relative_path>(p: P) -> bool {
matches!(
p.as_ref().components().next(),
Some(Component::CurDir | Component::ParentDir)
)
}
#[cfg(test)]
#[path = "../../../tests-rs/test_cmdbuilder.rs"]
mod tests;
================================================
FILE: crates/portable-pty-psmux/src/lib.rs
================================================
//! This crate provides a cross platform API for working with the
//! psuedo terminal (pty) interfaces provided by the system.
//! Unlike other crates in this space, this crate provides a set
//! of traits that allow selecting from different implementations
//! at runtime.
//! This crate is part of [wezterm](https://github.com/wezterm/wezterm).
//!
//! ```no_run
//! use portable_pty::{CommandBuilder, PtySize, native_pty_system, PtySystem};
//! use anyhow::Error;
//!
//! // Use the native pty implementation for the system
//! let pty_system = native_pty_system();
//!
//! // Create a new pty
//! let mut pair = pty_system.openpty(PtySize {
//! rows: 24,
//! cols: 80,
//! // Not all systems support pixel_width, pixel_height,
//! // but it is good practice to set it to something
//! // that matches the size of the selected font. That
//! // is more complex than can be shown here in this
//! // brief example though!
//! pixel_width: 0,
//! pixel_height: 0,
//! })?;
//!
//! // Spawn a shell into the pty
//! let cmd = CommandBuilder::new("bash");
//! let child = pair.slave.spawn_command(cmd)?;
//!
//! // Read and parse output from the pty with reader
//! let mut reader = pair.master.try_clone_reader()?;
//!
//! // Send data to the pty by writing to the master
//! writeln!(pair.master.take_writer()?, "ls -l\r\n")?;
//! # Ok::<(), Error>(())
//! ```
//!
use anyhow::Error;
use downcast_rs::{impl_downcast, Downcast};
#[cfg(unix)]
use libc;
#[cfg(feature = "serde_support")]
use serde_derive::*;
use std::io::Result as IoResult;
#[cfg(windows)]
use std::os::windows::prelude::{AsRawHandle, RawHandle};
pub mod cmdbuilder;
pub use cmdbuilder::CommandBuilder;
#[cfg(unix)]
pub mod unix;
#[cfg(windows)]
pub mod win;
pub mod serial;
/// Represents the size of the visible display area in the pty
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct PtySize {
/// The number of lines of text
pub rows: u16,
/// The number of columns of text
pub cols: u16,
/// The width of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_width: u16,
/// The height of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_height: u16,
}
impl Default for PtySize {
fn default() -> Self {
PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}
}
}
/// Represents the master/control end of the pty
pub trait MasterPty: Downcast + Send {
/// Inform the kernel and thus the child process that the window resized.
/// It will update the winsize information maintained by the kernel,
/// and generate a signal for the child to notice and update its state.
fn resize(&self, size: PtySize) -> Result<(), Error>;
/// Retrieves the size of the pty as known by the kernel
fn get_size(&self) -> Result;
/// Obtain a readable handle; output from the slave(s) is readable
/// via this stream.
fn try_clone_reader(&self) -> Result, Error>;
/// Obtain a writable handle; writing to it will send data to the
/// slave end.
/// Dropping the writer will send EOF to the slave end.
/// It is invalid to take the writer more than once.
fn take_writer(&self) -> Result, Error>;
/// If applicable to the type of the tty, return the local process id
/// of the process group or session leader
#[cfg(unix)]
fn process_group_leader(&self) -> Option;
/// If get_termios() and process_group_leader() are both implemented and
/// return Some, then as_raw_fd() should return the same underlying fd
/// associated with the stream. This is to enable applications that
/// "know things" to query similar information for themselves.
#[cfg(unix)]
fn as_raw_fd(&self) -> Option;
#[cfg(unix)]
fn tty_name(&self) -> Option;
/// If applicable to the type of the tty, return the termios
/// associated with the stream
#[cfg(unix)]
fn get_termios(&self) -> Option {
None
}
}
impl_downcast!(MasterPty);
/// Represents a child process spawned into the pty.
/// This handle can be used to wait for or terminate that child process.
pub trait Child: std::fmt::Debug + ChildKiller + Downcast + Send {
/// Poll the child to see if it has completed.
/// Does not block.
/// Returns None if the child has not yet terminated,
/// else returns its exit status.
fn try_wait(&mut self) -> IoResult>;
/// Blocks execution until the child process has completed,
/// yielding its exit status.
fn wait(&mut self) -> IoResult;
/// Returns the process identifier of the child process,
/// if applicable
fn process_id(&self) -> Option;
/// Returns the process handle of the child process, if applicable.
/// Only available on Windows.
#[cfg(windows)]
fn as_raw_handle(&self) -> Option;
}
impl_downcast!(Child);
/// Represents the ability to signal a Child to terminate
pub trait ChildKiller: std::fmt::Debug + Downcast + Send {
/// Terminate the child process
fn kill(&mut self) -> IoResult<()>;
/// Clone an object that can be split out from the Child in order
/// to send it signals independently from a thread that may be
/// blocked in `.wait`.
fn clone_killer(&self) -> Box;
}
impl_downcast!(ChildKiller);
/// Represents the slave side of a pty.
/// Can be used to spawn processes into the pty.
pub trait SlavePty {
/// Spawns the command specified by the provided CommandBuilder
fn spawn_command(&self, cmd: CommandBuilder) -> Result, Error>;
}
/// Represents the exit status of a child process.
#[derive(Debug, Clone)]
pub struct ExitStatus {
code: u32,
signal: Option,
}
impl ExitStatus {
/// Construct an ExitStatus from a process return code
pub fn with_exit_code(code: u32) -> Self {
Self { code, signal: None }
}
/// Construct an ExitStatus from a signal name
pub fn with_signal(signal: &str) -> Self {
Self {
code: 1,
signal: Some(signal.to_string()),
}
}
/// Returns true if the status indicates successful completion
pub fn success(&self) -> bool {
match self.signal {
None => self.code == 0,
Some(_) => false,
}
}
/// Returns the exit code that this ExitStatus was constructed with
pub fn exit_code(&self) -> u32 {
self.code
}
/// Returns the signal if present that this ExitStatus was constructed with
pub fn signal(&self) -> Option<&str> {
self.signal.as_deref()
}
}
impl From for ExitStatus {
fn from(status: std::process::ExitStatus) -> ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
let signame = unsafe { libc::strsignal(signal) };
let signal = if signame.is_null() {
format!("Signal {}", signal)
} else {
let signame = unsafe { std::ffi::CStr::from_ptr(signame) };
signame.to_string_lossy().to_string()
};
return ExitStatus {
code: status.code().map(|c| c as u32).unwrap_or(1),
signal: Some(signal),
};
}
}
let code =
status
.code()
.map(|c| c as u32)
.unwrap_or_else(|| if status.success() { 0 } else { 1 });
ExitStatus { code, signal: None }
}
}
impl std::fmt::Display for ExitStatus {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.success() {
write!(fmt, "Success")
} else {
match &self.signal {
Some(sig) => write!(fmt, "Terminated by {}", sig),
None => write!(fmt, "Exited with code {}", self.code),
}
}
}
}
pub struct PtyPair {
// slave is listed first so that it is dropped first.
// The drop order is stable and specified by rust rfc 1857
pub slave: Box,
pub master: Box,
}
/// The `PtySystem` trait allows an application to work with multiple
/// possible Pty implementations at runtime. This is important on
/// Windows systems which have a variety of implementations.
pub trait PtySystem: Downcast {
/// Create a new Pty instance with the window size set to the specified
/// dimensions. Returns a (master, slave) Pty pair. The master side
/// is used to drive the slave side.
fn openpty(&self, size: PtySize) -> anyhow::Result;
}
impl_downcast!(PtySystem);
impl Child for std::process::Child {
fn try_wait(&mut self) -> IoResult> {
std::process::Child::try_wait(self).map(|s| match s {
Some(s) => Some(s.into()),
None => None,
})
}
fn wait(&mut self) -> IoResult {
std::process::Child::wait(self).map(Into::into)
}
fn process_id(&self) -> Option {
Some(self.id())
}
#[cfg(windows)]
fn as_raw_handle(&self) -> Option {
Some(std::os::windows::io::AsRawHandle::as_raw_handle(self))
}
}
#[derive(Debug)]
struct ProcessSignaller {
pid: Option,
#[cfg(windows)]
handle: Option,
}
#[cfg(windows)]
impl ChildKiller for ProcessSignaller {
fn kill(&mut self) -> IoResult<()> {
if let Some(handle) = &self.handle {
unsafe {
if winapi::um::processthreadsapi::TerminateProcess(handle.as_raw_handle() as _, 127)
== 0
{
return Err(std::io::Error::last_os_error());
}
}
}
Ok(())
}
fn clone_killer(&self) -> Box {
Box::new(Self {
pid: self.pid,
handle: self.handle.as_ref().and_then(|h| h.try_clone().ok()),
})
}
}
#[cfg(unix)]
impl ChildKiller for ProcessSignaller {
fn kill(&mut self) -> IoResult<()> {
if let Some(pid) = self.pid {
let result = unsafe { libc::kill(pid as i32, libc::SIGHUP) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
fn clone_killer(&self) -> Box {
Box::new(Self { pid: self.pid })
}
}
impl ChildKiller for std::process::Child {
fn kill(&mut self) -> IoResult<()> {
#[cfg(unix)]
{
// On unix, we send the SIGHUP signal instead of trying to kill
// the process. The default behavior of a process receiving this
// signal is to be killed unless it configured a signal handler.
let result = unsafe { libc::kill(self.id() as i32, libc::SIGHUP) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
// We successfully delivered SIGHUP, but the semantics of Child::kill
// are that on success the process is dead or shortly about to
// terminate. Since SIGUP doesn't guarantee termination, we
// give the process a bit of a grace period to shutdown or do whatever
// it is doing in its signal handler befre we proceed with the
// full on kill.
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
if let Ok(Some(_)) = self.try_wait() {
// It completed, so report success!
return Ok(());
}
}
// it's still alive after a grace period, so proceed with a kill
}
std::process::Child::kill(self)
}
#[cfg(windows)]
fn clone_killer(&self) -> Box {
struct RawDup(RawHandle);
impl AsRawHandle for RawDup {
fn as_raw_handle(&self) -> RawHandle {
self.0
}
}
Box::new(ProcessSignaller {
pid: self.process_id(),
handle: Child::as_raw_handle(self)
.as_ref()
.and_then(|h| filedescriptor::OwnedHandle::dup(&RawDup(*h)).ok()),
})
}
#[cfg(unix)]
fn clone_killer(&self) -> Box {
Box::new(ProcessSignaller {
pid: self.process_id(),
})
}
}
pub fn native_pty_system() -> Box {
Box::new(NativePtySystem::default())
}
#[cfg(unix)]
pub type NativePtySystem = unix::UnixPtySystem;
#[cfg(windows)]
pub type NativePtySystem = win::conpty::ConPtySystem;
================================================
FILE: crates/portable-pty-psmux/src/serial.rs
================================================
//! This module implements a serial port based tty.
//! This is a bit different from the other implementations in that
//! we cannot explicitly spawn a process into the serial connection,
//! so we can only use a `CommandBuilder::new_default_prog` with the
//! `openpty` method.
//! On most (all?) systems, attempting to open multiple instances of
//! the same serial port will fail.
use crate::{
Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty, PtyPair, PtySize, PtySystem,
SlavePty,
};
use anyhow::{ensure, Context};
use filedescriptor::FileDescriptor;
use serial2::{CharSize, FlowControl, Parity, SerialPort, StopBits};
use std::cell::RefCell;
use std::ffi::{OsStr, OsString};
use std::io::{Read, Result as IoResult, Write};
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
type Handle = Arc;
pub struct SerialTty {
port: OsString,
baud: u32,
char_size: CharSize,
parity: Parity,
stop_bits: StopBits,
flow_control: FlowControl,
}
impl SerialTty {
pub fn new + ?Sized>(port: &T) -> Self {
Self {
port: port.as_ref().to_owned(),
baud: 9600,
char_size: CharSize::Bits8,
parity: Parity::None,
stop_bits: StopBits::One,
flow_control: FlowControl::XonXoff,
}
}
pub fn set_baud_rate(&mut self, baud: u32) {
self.baud = baud;
}
pub fn set_char_size(&mut self, char_size: CharSize) {
self.char_size = char_size;
}
pub fn set_parity(&mut self, parity: Parity) {
self.parity = parity;
}
pub fn set_stop_bits(&mut self, stop_bits: StopBits) {
self.stop_bits = stop_bits;
}
pub fn set_flow_control(&mut self, flow_control: FlowControl) {
self.flow_control = flow_control;
}
}
impl PtySystem for SerialTty {
fn openpty(&self, _size: PtySize) -> anyhow::Result {
let mut port = SerialPort::open(&self.port, self.baud)
.with_context(|| format!("openpty on serial port {:?}", self.port))?;
let mut settings = port.get_configuration()?;
settings.set_raw();
settings.set_baud_rate(self.baud)?;
settings.set_char_size(self.char_size);
settings.set_flow_control(self.flow_control);
settings.set_parity(self.parity);
settings.set_stop_bits(self.stop_bits);
log::debug!("serial settings: {:#?}", port.get_configuration());
port.set_configuration(&settings)?;
// The timeout needs to be rather short because, at least on Windows,
// a read with a long timeout will block a concurrent write from
// happening. In wezterm we tend to have a thread looping on read
// while writes happen occasionally from the gui thread, and if we
// make this timeout too long we can block the gui thread.
port.set_read_timeout(Duration::from_millis(50))?;
port.set_write_timeout(Duration::from_millis(50))?;
let port: Handle = Arc::new(port);
Ok(PtyPair {
slave: Box::new(Slave {
port: Arc::clone(&port),
}),
master: Box::new(Master {
port,
took_writer: RefCell::new(false),
}),
})
}
}
struct Slave {
port: Handle,
}
impl SlavePty for Slave {
fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result> {
ensure!(
cmd.is_default_prog(),
"can only use default prog commands with serial tty implementations"
);
Ok(Box::new(SerialChild {
port: Arc::clone(&self.port),
}))
}
}
/// There isn't really a child process on the end of the serial connection,
/// so all of the Child trait impls are NOP
struct SerialChild {
port: Handle,
}
// An anemic impl of Debug to satisfy some indirect trait bounds
impl std::fmt::Debug for SerialChild {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
fmt.debug_struct("SerialChild").finish()
}
}
impl Child for SerialChild {
fn try_wait(&mut self) -> IoResult> {
Ok(None)
}
fn wait(&mut self) -> IoResult {
// There isn't really a child process to wait for,
// as the serial connection never really "dies",
// however, for something like a USB serial port,
// if it is unplugged then it logically is terminated.
// We read the CD (carrier detect) signal periodically
// to see if the device has gone away: we actually discard
// the CD value itself and just look for an error state.
// We could potentially also decide to call CD==false the
// same thing as the "child" completing.
loop {
std::thread::sleep(Duration::from_secs(5));
let port = &self.port;
if let Err(err) = port.read_cd() {
log::error!("Error reading carrier detect: {:#}", err);
return Ok(ExitStatus::with_exit_code(1));
}
}
}
fn process_id(&self) -> Option {
None
}
#[cfg(windows)]
fn as_raw_handle(&self) -> Option {
None
}
}
impl ChildKiller for SerialChild {
fn kill(&mut self) -> IoResult<()> {
Ok(())
}
fn clone_killer(&self) -> Box {
Box::new(SerialChildKiller)
}
}
#[derive(Debug)]
struct SerialChildKiller;
impl ChildKiller for SerialChildKiller {
fn kill(&mut self) -> IoResult<()> {
Ok(())
}
fn clone_killer(&self) -> Box {
Box::new(SerialChildKiller)
}
}
struct Master {
port: Handle,
took_writer: RefCell,
}
struct MasterWriter {
port: Handle,
}
impl Write for MasterWriter {
fn write(&mut self, buf: &[u8]) -> Result {
self.port.write(buf)
}
fn flush(&mut self) -> Result<(), std::io::Error> {
self.port.flush()
}
}
impl MasterPty for Master {
fn resize(&self, _size: PtySize) -> anyhow::Result<()> {
// Serial ports have no concept of size
Ok(())
}
fn get_size(&self) -> anyhow::Result {
// Serial ports have no concept of size
Ok(PtySize::default())
}
fn try_clone_reader(&self) -> anyhow::Result> {
// We rely on the fact that SystemPort implements the traits
// that expose the underlying file descriptor, and that direct
// reads from that return the raw data that we want
let fd = FileDescriptor::dup(&*self.port)?;
Ok(Box::new(Reader { fd }))
}
fn take_writer(&self) -> anyhow::Result> {
if *self.took_writer.borrow() {
anyhow::bail!("cannot take writer more than once");
}
*self.took_writer.borrow_mut() = true;
let port = Arc::clone(&self.port);
Ok(Box::new(MasterWriter { port }))
}
#[cfg(unix)]
fn process_group_leader(&self) -> Option {
// N/A: there is no local process
None
}
#[cfg(unix)]
fn as_raw_fd(&self) -> Option {
None
}
#[cfg(unix)]
fn tty_name(&self) -> Option {
None
}
}
struct Reader {
fd: FileDescriptor,
}
impl Read for Reader {
fn read(&mut self, buf: &mut [u8]) -> Result {
// On windows, this self.fd.read will block for up to the time we set
// as the timeout when we set up the port, but on unix it will
// never block.
loop {
#[cfg(unix)]
{
use filedescriptor::{poll, pollfd, AsRawSocketDescriptor, POLLIN};
// The serial crate puts the serial port in non-blocking mode,
// so we must explicitly poll for ourselves here to avoid a
// busy loop.
let mut poll_array = [pollfd {
fd: self.fd.as_socket_descriptor(),
events: POLLIN,
revents: 0,
}];
let _ = poll(&mut poll_array, None);
}
match self.fd.read(buf) {
Ok(0) => {
if cfg!(windows) {
// Read timeout with no data available yet;
// loop and try again.
continue;
}
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"EOF on serial port",
));
}
Ok(size) => {
return Ok(size);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
continue;
}
log::error!("serial read error: {}", e);
return Err(e);
}
}
}
}
}
================================================
FILE: crates/portable-pty-psmux/src/unix.rs
================================================
//! Working with pseudo-terminals
use crate::{Child, CommandBuilder, MasterPty, PtyPair, PtySize, PtySystem, SlavePty};
use anyhow::{bail, Error};
use filedescriptor::FileDescriptor;
use libc::{self, winsize};
use std::cell::RefCell;
use std::ffi::OsStr;
use std::io::{Read, Write};
use std::os::fd::AsFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::{AsRawFd, FromRawFd};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::{io, mem, ptr};
pub use std::os::unix::io::RawFd;
#[derive(Default)]
pub struct UnixPtySystem {}
fn openpty(size: PtySize) -> anyhow::Result<(UnixMasterPty, UnixSlavePty)> {
let mut master: RawFd = -1;
let mut slave: RawFd = -1;
let mut size = winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let result = unsafe {
// BSDish systems may require mut pointers to some args
#[allow(clippy::unnecessary_mut_passed)]
libc::openpty(
&mut master,
&mut slave,
ptr::null_mut(),
ptr::null_mut(),
&mut size,
)
};
if result != 0 {
bail!("failed to openpty: {:?}", io::Error::last_os_error());
}
let tty_name = tty_name(slave);
let master = UnixMasterPty {
fd: PtyFd(unsafe { FileDescriptor::from_raw_fd(master) }),
took_writer: RefCell::new(false),
tty_name,
};
let slave = UnixSlavePty {
fd: PtyFd(unsafe { FileDescriptor::from_raw_fd(slave) }),
};
// Ensure that these descriptors will get closed when we execute
// the child process. This is done after constructing the Pty
// instances so that we ensure that the Ptys get drop()'d if
// the cloexec() functions fail (unlikely!).
cloexec(master.fd.as_raw_fd())?;
cloexec(slave.fd.as_raw_fd())?;
Ok((master, slave))
}
impl PtySystem for UnixPtySystem {
fn openpty(&self, size: PtySize) -> anyhow::Result {
let (master, slave) = openpty(size)?;
Ok(PtyPair {
master: Box::new(master),
slave: Box::new(slave),
})
}
}
struct PtyFd(pub FileDescriptor);
impl std::ops::Deref for PtyFd {
type Target = FileDescriptor;
fn deref(&self) -> &FileDescriptor {
&self.0
}
}
impl std::ops::DerefMut for PtyFd {
fn deref_mut(&mut self) -> &mut FileDescriptor {
&mut self.0
}
}
impl Read for PtyFd {
fn read(&mut self, buf: &mut [u8]) -> Result {
match self.0.read(buf) {
Err(ref e) if e.raw_os_error() == Some(libc::EIO) => {
// EIO indicates that the slave pty has been closed.
// Treat this as EOF so that std::io::Read::read_to_string
// and similar functions gracefully terminate when they
// encounter this condition
Ok(0)
}
x => x,
}
}
}
fn tty_name(fd: RawFd) -> Option {
let mut buf = vec![0 as std::ffi::c_char; 128];
loop {
let res = unsafe { libc::ttyname_r(fd, buf.as_mut_ptr(), buf.len()) };
if res == libc::ERANGE {
if buf.len() > 64 * 1024 {
// on macOS, if the buf is "too big", ttyname_r can
// return ERANGE, even though that is supposed to
// indicate buf is "too small".
return None;
}
buf.resize(buf.len() * 2, 0 as std::ffi::c_char);
continue;
}
return if res == 0 {
let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
let osstr = OsStr::from_bytes(cstr.to_bytes());
Some(PathBuf::from(osstr))
} else {
None
};
}
}
/// On Big Sur, Cocoa leaks various file descriptors to child processes,
/// so we need to make a pass through the open descriptors beyond just the
/// stdio descriptors and close them all out.
/// This is approximately equivalent to the darwin `posix_spawnattr_setflags`
/// option POSIX_SPAWN_CLOEXEC_DEFAULT which is used as a bit of a cheat
/// on macOS.
/// On Linux, gnome/mutter leak shell extension fds to wezterm too, so we
/// also need to make an effort to clean up the mess.
///
/// This function enumerates the open filedescriptors in the current process
/// and then will forcibly call close(2) on each open fd that is numbered
/// 3 or higher, effectively closing all descriptors except for the stdio
/// streams.
///
/// The implementation of this function relies on `/dev/fd` being available
/// to provide the list of open fds. Any errors in enumerating or closing
/// the fds are silently ignored.
pub fn close_random_fds() {
// FreeBSD, macOS and presumably other BSDish systems have /dev/fd as
// a directory listing the current fd numbers for the process.
//
// On Linux, /dev/fd is a symlink to /proc/self/fd
if let Ok(dir) = std::fs::read_dir("/dev/fd") {
let mut fds = vec![];
for entry in dir {
if let Some(num) = entry
.ok()
.map(|e| e.file_name())
.and_then(|s| s.into_string().ok())
.and_then(|n| n.parse::().ok())
{
if num > 2 {
fds.push(num);
}
}
}
for fd in fds {
unsafe {
libc::close(fd);
}
}
}
}
impl PtyFd {
fn resize(&self, size: PtySize) -> Result<(), Error> {
let ws_size = winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
if unsafe {
libc::ioctl(
self.0.as_raw_fd(),
libc::TIOCSWINSZ as _,
&ws_size as *const _,
)
} != 0
{
bail!(
"failed to ioctl(TIOCSWINSZ): {:?}",
io::Error::last_os_error()
);
}
Ok(())
}
fn get_size(&self) -> Result {
let mut size: winsize = unsafe { mem::zeroed() };
if unsafe {
libc::ioctl(
self.0.as_raw_fd(),
libc::TIOCGWINSZ as _,
&mut size as *mut _,
)
} != 0
{
bail!(
"failed to ioctl(TIOCGWINSZ): {:?}",
io::Error::last_os_error()
);
}
Ok(PtySize {
rows: size.ws_row,
cols: size.ws_col,
pixel_width: size.ws_xpixel,
pixel_height: size.ws_ypixel,
})
}
fn spawn_command(&self, builder: CommandBuilder) -> anyhow::Result {
let configured_umask = builder.umask;
let mut cmd = builder.as_command()?;
let controlling_tty = builder.get_controlling_tty();
unsafe {
cmd.stdin(self.as_stdio()?)
.stdout(self.as_stdio()?)
.stderr(self.as_stdio()?)
.pre_exec(move || {
// Clean up a few things before we exec the program
// Clear out any potentially problematic signal
// dispositions that we might have inherited
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
}
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
// Establish ourselves as a session leader.
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
// Clippy wants us to explicitly cast TIOCSCTTY using
// type::from(), but the size and potentially signedness
// are system dependent, which is why we're using `as _`.
// Suppress this lint for this section of code.
#[allow(clippy::cast_lossless)]
if controlling_tty {
// Set the pty as the controlling terminal.
// Failure to do this means that delivery of
// SIGWINCH won't happen when we resize the
// terminal, among other undesirable effects.
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(io::Error::last_os_error());
}
}
close_random_fds();
if let Some(mask) = configured_umask {
libc::umask(mask);
}
Ok(())
})
};
let mut child = cmd.spawn()?;
// Ensure that we close out the slave fds that Child retains;
// they are not what we need (we need the master side to reference
// them) and won't work in the usual way anyway.
// In practice these are None, but it seems best to be move them
// out in case the behavior of Command changes in the future.
child.stdin.take();
child.stdout.take();
child.stderr.take();
Ok(child)
}
}
/// Represents the master end of a pty.
/// The file descriptor will be closed when the Pty is dropped.
struct UnixMasterPty {
fd: PtyFd,
took_writer: RefCell,
tty_name: Option,
}
/// Represents the slave end of a pty.
/// The file descriptor will be closed when the Pty is dropped.
struct UnixSlavePty {
fd: PtyFd,
}
/// Helper function to set the close-on-exec flag for a raw descriptor
fn cloexec(fd: RawFd) -> Result<(), Error> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags == -1 {
bail!(
"fcntl to read flags failed: {:?}",
io::Error::last_os_error()
);
}
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
if result == -1 {
bail!(
"fcntl to set CLOEXEC failed: {:?}",
io::Error::last_os_error()
);
}
Ok(())
}
impl SlavePty for UnixSlavePty {
fn spawn_command(
&self,
builder: CommandBuilder,
) -> Result, Error> {
Ok(Box::new(self.fd.spawn_command(builder)?))
}
}
impl MasterPty for UnixMasterPty {
fn resize(&self, size: PtySize) -> Result<(), Error> {
self.fd.resize(size)
}
fn get_size(&self) -> Result {
self.fd.get_size()
}
fn try_clone_reader(&self) -> Result, Error> {
let fd = PtyFd(self.fd.try_clone()?);
Ok(Box::new(fd))
}
fn take_writer(&self) -> Result, Error> {
if *self.took_writer.borrow() {
anyhow::bail!("cannot take writer more than once");
}
*self.took_writer.borrow_mut() = true;
let fd = PtyFd(self.fd.try_clone()?);
Ok(Box::new(UnixMasterWriter { fd }))
}
fn as_raw_fd(&self) -> Option {
Some(self.fd.0.as_raw_fd())
}
fn tty_name(&self) -> Option {
self.tty_name.clone()
}
fn process_group_leader(&self) -> Option {
match unsafe { libc::tcgetpgrp(self.fd.0.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn get_termios(&self) -> Option {
nix::sys::termios::tcgetattr(self.fd.0.as_fd()).ok()
}
}
/// Represents the master end of a pty.
/// EOT will be sent, and then the file descriptor will be closed when
/// the Pty is dropped.
struct UnixMasterWriter {
fd: PtyFd,
}
impl Drop for UnixMasterWriter {
fn drop(&mut self) {
let mut t: libc::termios = unsafe { std::mem::MaybeUninit::zeroed().assume_init() };
if unsafe { libc::tcgetattr(self.fd.0.as_raw_fd(), &mut t) } == 0 {
// EOF is only interpreted after a newline, so if it is set,
// we send a newline followed by EOF.
let eot = t.c_cc[libc::VEOF];
if eot != 0 {
let _ = self.fd.0.write_all(&[b'\n', eot]);
}
}
}
}
impl Write for UnixMasterWriter {
fn write(&mut self, buf: &[u8]) -> Result {
self.fd.write(buf)
}
fn flush(&mut self) -> Result<(), io::Error> {
self.fd.flush()
}
}
================================================
FILE: crates/portable-pty-psmux/src/win/conpty.rs
================================================
use crate::cmdbuilder::CommandBuilder;
use crate::win::psuedocon::PsuedoCon;
use crate::{Child, MasterPty, PtyPair, PtySize, PtySystem, SlavePty};
use anyhow::Error;
use filedescriptor::FileDescriptor;
use std::sync::{Arc, Mutex};
use winapi::um::wincon::COORD;
/// Create a pipe pair with an explicit buffer size.
///
/// Windows Terminal uses 128 KB pipe buffers for ConPTY I/O. The default
/// `CreatePipe(..., 0)` typically gets 4 KB, which forces more frequent
/// kernel transitions during high-throughput output (e.g. `cat large_file`).
/// Using 64 KB matches Windows Terminal's approach and reduces syscall
/// overhead for both input (mouse/keyboard) and output.
fn create_pipe_with_buffer(size: u32) -> anyhow::Result<(FileDescriptor, FileDescriptor)> {
use std::os::windows::io::FromRawHandle;
use std::ptr;
use winapi::shared::minwindef::TRUE;
use winapi::um::handleapi::INVALID_HANDLE_VALUE;
use winapi::um::minwinbase::SECURITY_ATTRIBUTES;
use winapi::um::namedpipeapi::CreatePipe;
use winapi::um::winnt::HANDLE;
let mut sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::() as u32,
lpSecurityDescriptor: ptr::null_mut(),
bInheritHandle: TRUE as _,
};
let mut read: HANDLE = INVALID_HANDLE_VALUE;
let mut write: HANDLE = INVALID_HANDLE_VALUE;
if unsafe { CreatePipe(&mut read, &mut write, &mut sa, size) } == 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(unsafe {(
FileDescriptor::from_raw_handle(read as _),
FileDescriptor::from_raw_handle(write as _),
)})
}
#[derive(Default)]
pub struct ConPtySystem {}
impl PtySystem for ConPtySystem {
fn openpty(&self, size: PtySize) -> anyhow::Result {
// Use 64KB pipe buffers (Windows Terminal uses 128KB).
// Default CreatePipe(..., 0) = ~4KB, causing frequent kernel round-trips.
const PIPE_BUF: u32 = 64 * 1024;
let (stdin_read, stdin_write) = create_pipe_with_buffer(PIPE_BUF)?;
let (stdout_read, stdout_write) = create_pipe_with_buffer(PIPE_BUF)?;
let con = PsuedoCon::new(
COORD {
X: size.cols as i16,
Y: size.rows as i16,
},
stdin_read,
stdout_write,
)?;
let master = ConPtyMasterPty {
inner: Arc::new(Mutex::new(Inner {
con,
readable: stdout_read,
writable: Some(stdin_write),
size,
})),
};
let slave = ConPtySlavePty {
inner: master.inner.clone(),
};
Ok(PtyPair {
master: Box::new(master),
slave: Box::new(slave),
})
}
}
struct Inner {
con: PsuedoCon,
readable: FileDescriptor,
writable: Option,
size: PtySize,
}
impl Inner {
pub fn resize(
&mut self,
num_rows: u16,
num_cols: u16,
pixel_width: u16,
pixel_height: u16,
) -> Result<(), Error> {
self.con.resize(COORD {
X: num_cols as i16,
Y: num_rows as i16,
})?;
self.size = PtySize {
rows: num_rows,
cols: num_cols,
pixel_width,
pixel_height,
};
Ok(())
}
}
#[derive(Clone)]
pub struct ConPtyMasterPty {
inner: Arc>,
}
pub struct ConPtySlavePty {
inner: Arc>,
}
impl MasterPty for ConPtyMasterPty {
fn resize(&self, size: PtySize) -> anyhow::Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.resize(size.rows, size.cols, size.pixel_width, size.pixel_height)
}
fn get_size(&self) -> Result {
let inner = self.inner.lock().unwrap();
Ok(inner.size.clone())
}
fn try_clone_reader(&self) -> anyhow::Result> {
Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?))
}
fn take_writer(&self) -> anyhow::Result> {
Ok(Box::new(
self.inner
.lock()
.unwrap()
.writable
.take()
.ok_or_else(|| anyhow::anyhow!("writer already taken"))?,
))
}
}
impl SlavePty for ConPtySlavePty {
fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result> {
let mut inner = self.inner.lock().unwrap();
match inner.con.spawn_command(cmd.clone()) {
Ok(child) => Ok(Box::new(child)),
Err(e) if inner.con.used_passthrough && is_invalid_parameter(&e) => {
// CreateProcessW rejected the ConPTY handle that was created
// with PSEUDOCONSOLE_PASSTHROUGH_MODE. Some Windows 11 builds
// (notably Insider/Canary builds like 26200) accept the flag
// during CreatePseudoConsole but later fail in CreateProcessW
// with ERROR_INVALID_PARAMETER (87).
//
// Recovery: recreate the ConPTY without passthrough mode and
// create fresh pipe pairs for the new pseudo-console.
log::warn!(
"CreateProcessW failed with ERROR_INVALID_PARAMETER while using \
ConPTY passthrough mode; retrying without passthrough"
);
const PIPE_BUF: u32 = 64 * 1024;
let (stdin_read, stdin_write) = create_pipe_with_buffer(PIPE_BUF)?;
let (stdout_read, stdout_write) = create_pipe_with_buffer(PIPE_BUF)?;
let new_con = PsuedoCon::new_without_passthrough(
COORD {
X: inner.size.cols as i16,
Y: inner.size.rows as i16,
},
stdin_read,
stdout_write,
)?;
// Replace the ConPTY and pipe endpoints inside Inner.
// At this point nobody has cloned the reader or taken the
// writer yet (pane.rs acquires them after spawn_command),
// so the old FileDescriptors are dropped cleanly.
inner.con = new_con;
inner.readable = stdout_read;
inner.writable = Some(stdin_write);
let child = inner.con.spawn_command(cmd)?;
Ok(Box::new(child))
}
Err(e) => Err(e),
}
}
}
/// Check if an error chain contains Windows ERROR_INVALID_PARAMETER (87).
/// The OS error number is locale-independent; the textual message varies
/// (e.g. "Falscher Parameter" in German).
fn is_invalid_parameter(e: &anyhow::Error) -> bool {
let msg = format!("{}", e);
msg.contains("os error 87")
}
================================================
FILE: crates/portable-pty-psmux/src/win/mod.rs
================================================
use crate::{Child, ChildKiller, ExitStatus};
use anyhow::Context as _;
use std::io::{Error as IoError, Result as IoResult};
use std::os::windows::io::{AsRawHandle, RawHandle};
use std::pin::Pin;
use std::sync::Mutex;
use std::task::{Context, Poll};
use winapi::shared::minwindef::DWORD;
use winapi::um::minwinbase::STILL_ACTIVE;
use winapi::um::processthreadsapi::*;
use winapi::um::synchapi::WaitForSingleObject;
use winapi::um::winbase::INFINITE;
pub mod conpty;
mod procthreadattr;
mod psuedocon;
use filedescriptor::OwnedHandle;
#[derive(Debug)]
pub struct WinChild {
proc: Mutex,
}
impl WinChild {
fn is_complete(&mut self) -> IoResult> {
let mut status: DWORD = 0;
let proc = self.proc.lock().unwrap().try_clone().unwrap();
let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
if res != 0 {
if status == STILL_ACTIVE {
Ok(None)
} else {
Ok(Some(ExitStatus::with_exit_code(status)))
}
} else {
Ok(None)
}
}
fn do_kill(&mut self) -> IoResult<()> {
let proc = self.proc.lock().unwrap().try_clone().unwrap();
let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) };
let err = IoError::last_os_error();
if res != 0 {
Err(err)
} else {
Ok(())
}
}
}
impl ChildKiller for WinChild {
fn kill(&mut self) -> IoResult<()> {
self.do_kill().ok();
Ok(())
}
fn clone_killer(&self) -> Box {
let proc = self.proc.lock().unwrap().try_clone().unwrap();
Box::new(WinChildKiller { proc })
}
}
#[derive(Debug)]
pub struct WinChildKiller {
proc: OwnedHandle,
}
impl ChildKiller for WinChildKiller {
fn kill(&mut self) -> IoResult<()> {
let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) };
let err = IoError::last_os_error();
if res != 0 {
Err(err)
} else {
Ok(())
}
}
fn clone_killer(&self) -> Box {
let proc = self.proc.try_clone().unwrap();
Box::new(WinChildKiller { proc })
}
}
impl Child for WinChild {
fn try_wait(&mut self) -> IoResult> {
self.is_complete()
}
fn wait(&mut self) -> IoResult {
if let Ok(Some(status)) = self.try_wait() {
return Ok(status);
}
let proc = self.proc.lock().unwrap().try_clone().unwrap();
unsafe {
WaitForSingleObject(proc.as_raw_handle() as _, INFINITE);
}
let mut status: DWORD = 0;
let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
if res != 0 {
Ok(ExitStatus::with_exit_code(status))
} else {
Err(IoError::last_os_error())
}
}
fn process_id(&self) -> Option {
let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) };
if res == 0 {
None
} else {
Some(res)
}
}
fn as_raw_handle(&self) -> Option {
let proc = self.proc.lock().unwrap();
Some(proc.as_raw_handle())
}
}
impl std::future::Future for WinChild {
type Output = anyhow::Result;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> {
match self.is_complete() {
Ok(Some(status)) => Poll::Ready(Ok(status)),
Err(err) => Poll::Ready(Err(err).context("Failed to retrieve process exit status")),
Ok(None) => {
struct PassRawHandleToWaiterThread(pub RawHandle);
unsafe impl Send for PassRawHandleToWaiterThread {}
let proc = self.proc.lock().unwrap().try_clone()?;
let handle = PassRawHandleToWaiterThread(proc.as_raw_handle());
let waker = cx.waker().clone();
std::thread::spawn(move || {
unsafe {
WaitForSingleObject(handle.0 as _, INFINITE);
}
waker.wake();
});
Poll::Pending
}
}
}
}
================================================
FILE: crates/portable-pty-psmux/src/win/procthreadattr.rs
================================================
use crate::win::psuedocon::HPCON;
use anyhow::{ensure, Error};
use std::io::Error as IoError;
use std::{mem, ptr};
use winapi::shared::minwindef::DWORD;
use winapi::um::processthreadsapi::*;
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016;
pub struct ProcThreadAttributeList {
data: Vec,
}
impl ProcThreadAttributeList {
pub fn with_capacity(num_attributes: DWORD) -> Result {
let mut bytes_required: usize = 0;
unsafe {
InitializeProcThreadAttributeList(
ptr::null_mut(),
num_attributes,
0,
&mut bytes_required,
)
};
let mut data = Vec::with_capacity(bytes_required);
// We have the right capacity, so force the vec to consider itself
// that length. The contents of those bytes will be maintained
// by the win32 apis used in this impl.
unsafe { data.set_len(bytes_required) };
let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _;
let res = unsafe {
InitializeProcThreadAttributeList(attr_ptr, num_attributes, 0, &mut bytes_required)
};
ensure!(
res != 0,
"InitializeProcThreadAttributeList failed: {}",
IoError::last_os_error()
);
Ok(Self { data })
}
pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
self.data.as_mut_slice().as_mut_ptr() as *mut _
}
pub fn set_pty(&mut self, con: HPCON) -> Result<(), Error> {
let res = unsafe {
UpdateProcThreadAttribute(
self.as_mut_ptr(),
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
con,
mem::size_of::(),
ptr::null_mut(),
ptr::null_mut(),
)
};
ensure!(
res != 0,
"UpdateProcThreadAttribute failed: {}",
IoError::last_os_error()
);
Ok(())
}
}
impl Drop for ProcThreadAttributeList {
fn drop(&mut self) {
unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) };
}
}
================================================
FILE: crates/portable-pty-psmux/src/win/psuedocon.rs
================================================
use super::WinChild;
use crate::cmdbuilder::CommandBuilder;
use crate::win::procthreadattr::ProcThreadAttributeList;
use anyhow::{bail, ensure, Error};
use filedescriptor::{FileDescriptor, OwnedHandle};
use lazy_static::lazy_static;
use shared_library::shared_library;
use std::ffi::OsString;
use std::io::Error as IoError;
use std::os::windows::ffi::OsStringExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle};
use std::path::Path;
use std::sync::Mutex;
use std::{mem, ptr};
use winapi::shared::minwindef::DWORD;
use winapi::shared::winerror::{HRESULT, S_OK};
use winapi::um::handleapi::*;
use winapi::um::processthreadsapi::*;
use winapi::um::winbase::{
CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, STARTUPINFOEXW,
};
use winapi::um::wincon::COORD;
use winapi::um::winnt::HANDLE;
pub type HPCON = HANDLE;
pub const PSUEDOCONSOLE_INHERIT_CURSOR: DWORD = 0x1;
pub const PSEUDOCONSOLE_RESIZE_QUIRK: DWORD = 0x2;
pub const PSEUDOCONSOLE_WIN32_INPUT_MODE: DWORD = 0x4;
pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8;
shared_library!(ConPtyFuncs,
pub fn CreatePseudoConsole(
size: COORD,
hInput: HANDLE,
hOutput: HANDLE,
flags: DWORD,
hpc: *mut HPCON
) -> HRESULT,
pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT,
pub fn ClosePseudoConsole(hpc: HPCON),
);
fn load_conpty() -> ConPtyFuncs {
// Always use the system kernel32.dll ConPTY implementation.
// Do NOT try to sideload conpty.dll — terminal emulators like WezTerm
// bundle their own conpty.dll + OpenConsole.exe, and the DLL search order
// can pick those up when psmux runs inside such a terminal. Using a
// foreign conpty.dll causes blank panes and broken I/O because the
// bundled OpenConsole.exe may not be compatible with our ConPTY flags
// (PASSTHROUGH_MODE, WIN32_INPUT_MODE, etc.).
ConPtyFuncs::open(Path::new("kernel32.dll")).expect(
"this system does not support conpty. Windows 10 October 2018 or newer is required",
)
}
lazy_static! {
static ref CONPTY: ConPtyFuncs = load_conpty();
}
pub struct PsuedoCon {
con: HPCON,
/// Whether this ConPTY was created with PSEUDOCONSOLE_PASSTHROUGH_MODE.
/// Used by the retry logic in ConPtySlavePty::spawn_command to decide
/// whether a fallback without passthrough is worth attempting.
pub used_passthrough: bool,
}
unsafe impl Send for PsuedoCon {}
unsafe impl Sync for PsuedoCon {}
impl Drop for PsuedoCon {
fn drop(&mut self) {
unsafe { (CONPTY.ClosePseudoConsole)(self.con) };
}
}
/// Returns true if the current Windows build supports ConPTY passthrough mode.
/// PSEUDOCONSOLE_PASSTHROUGH_MODE requires Windows 11 22H2 (build 22621+).
/// On older Windows versions, the flag may be silently accepted but produce
/// broken ConPTY output (no Win32 Console API translation).
///
/// Respects `PSMUX_NO_PASSTHROUGH=1` environment variable to let users
/// force-disable passthrough mode on builds where it causes CreateProcessW
/// to fail with ERROR_INVALID_PARAMETER (87).
fn supports_passthrough_mode() -> bool {
if std::env::var("PSMUX_NO_PASSTHROUGH")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
{
log::info!("ConPTY passthrough mode disabled via PSMUX_NO_PASSTHROUGH");
return false;
}
let ver = unsafe {
let mut info: winapi::um::winnt::OSVERSIONINFOW = mem::zeroed();
info.dwOSVersionInfoSize = mem::size_of::() as u32;
// RtlGetVersion is used because GetVersionEx lies on Windows 10+
// unless the application has a compatibility manifest.
type RtlGetVersionFn = unsafe extern "system" fn(*mut winapi::um::winnt::OSVERSIONINFOW) -> i32;
let ntdll = winapi::um::libloaderapi::GetModuleHandleW(
['n' as u16, 't' as u16, 'd' as u16, 'l' as u16, 'l' as u16, '.' as u16,
'd' as u16, 'l' as u16, 'l' as u16, 0].as_ptr()
);
if ntdll.is_null() {
return false;
}
let func = winapi::um::libloaderapi::GetProcAddress(
ntdll,
b"RtlGetVersion\0".as_ptr() as *const i8,
);
if func.is_null() {
return false;
}
let rtl_get_version: RtlGetVersionFn = mem::transmute(func);
rtl_get_version(&mut info);
info
};
// Windows 11 22H2 = build 22621
ver.dwBuildNumber >= 22621
}
impl PsuedoCon {
pub fn new(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result {
let mut con: HPCON = INVALID_HANDLE_VALUE;
let base_flags = PSUEDOCONSOLE_INHERIT_CURSOR
| PSEUDOCONSOLE_RESIZE_QUIRK
| PSEUDOCONSOLE_WIN32_INPUT_MODE;
// Use PSEUDOCONSOLE_PASSTHROUGH_MODE on Windows 11 22H2+ to relay
// VT sequences (including DECSCUSR cursor shapes) from child processes
// directly through the output pipe. On older Windows, this flag is
// silently accepted but breaks Win32 Console API translation, so we
// only attempt it on known-good builds.
if supports_passthrough_mode() {
let result = unsafe {
(CONPTY.CreatePseudoConsole)(
size,
input.as_raw_handle() as _,
output.as_raw_handle() as _,
base_flags | PSEUDOCONSOLE_PASSTHROUGH_MODE,
&mut con,
)
};
if result == S_OK {
return Ok(Self { con, used_passthrough: true });
}
// If the API call failed despite being on a supported build,
// fall through to the standard path.
con = INVALID_HANDLE_VALUE;
}
let result = unsafe {
(CONPTY.CreatePseudoConsole)(
size,
input.as_raw_handle() as _,
output.as_raw_handle() as _,
base_flags,
&mut con,
)
};
ensure!(
result == S_OK,
"failed to create psuedo console: HRESULT {}",
result
);
Ok(Self { con, used_passthrough: false })
}
/// Create a ConPTY explicitly without passthrough mode, regardless of
/// Windows build version. Used by the retry logic when CreateProcessW
/// rejects the passthrough ConPTY handle.
pub fn new_without_passthrough(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result {
let mut con: HPCON = INVALID_HANDLE_VALUE;
let base_flags = PSUEDOCONSOLE_INHERIT_CURSOR
| PSEUDOCONSOLE_RESIZE_QUIRK
| PSEUDOCONSOLE_WIN32_INPUT_MODE;
let result = unsafe {
(CONPTY.CreatePseudoConsole)(
size,
input.as_raw_handle() as _,
output.as_raw_handle() as _,
base_flags,
&mut con,
)
};
ensure!(
result == S_OK,
"failed to create psuedo console (no passthrough): HRESULT {}",
result
);
Ok(Self { con, used_passthrough: false })
}
pub fn resize(&self, size: COORD) -> Result<(), Error> {
let result = unsafe { (CONPTY.ResizePseudoConsole)(self.con, size) };
ensure!(
result == S_OK,
"failed to resize console to {}x{}: HRESULT: {}",
size.X,
size.Y,
result
);
Ok(())
}
pub fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result {
let mut si: STARTUPINFOEXW = unsafe { mem::zeroed() };
si.StartupInfo.cb = mem::size_of::() as u32;
// Note: we deliberately do NOT set STARTF_USESTDHANDLES with
// INVALID_HANDLE_VALUE for stdio. MSDN explicitly requires
// STARTF_USESTDHANDLES to be paired with bInheritHandles=TRUE,
// and we use bInheritHandles=FALSE below. Most Windows builds
// tolerate the combination silently (because INVALID_HANDLE_VALUE
// is a sentinel rather than a real handle), but newer/restricted
// configurations — Win 11 26200, Microsoft-account profiles with
// tighter token policies, certain WDAC/AppLocker rule sets — now
// enforce the contract strictly and reject the call with
// ERROR_INVALID_PARAMETER (87). See psmux issue #167.
//
// ConPTY routes stdio through the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
// attribute on the attribute list, so the child gets correct stdio
// regardless of dwFlags. bInheritHandles=FALSE prevents leaking
// any other inheritable handles.
let mut attrs = ProcThreadAttributeList::with_capacity(1)?;
attrs.set_pty(self.con)?;
si.lpAttributeList = attrs.as_mut_ptr();
let mut pi: PROCESS_INFORMATION = unsafe { mem::zeroed() };
let (mut exe, mut cmdline) = cmd.cmdline()?;
let cmd_os = OsString::from_wide(&cmdline);
let cwd = cmd.current_directory();
let res = unsafe {
CreateProcessW(
exe.as_mut_slice().as_mut_ptr(),
cmdline.as_mut_slice().as_mut_ptr(),
ptr::null_mut(),
ptr::null_mut(),
0,
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
cmd.environment_block().as_mut_slice().as_mut_ptr() as *mut _,
cwd.as_ref()
.map(|c| c.as_slice().as_ptr())
.unwrap_or(ptr::null()),
&mut si.StartupInfo,
&mut pi,
)
};
if res == 0 {
let err = IoError::last_os_error();
let msg = format!(
"CreateProcessW `{:?}` in cwd `{:?}` failed: {}",
cmd_os,
cwd.as_ref().map(|c| OsString::from_wide(c)),
err
);
log::error!("{}", msg);
bail!("{}", msg);
}
// Make sure we close out the thread handle so we don't leak it;
// we do this simply by making it owned
let _main_thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread as _) };
let proc = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as _) };
Ok(WinChild {
proc: Mutex::new(proc),
})
}
}
================================================
FILE: crates/vt100-psmux/.cargo-ok
================================================
{"v":1}
================================================
FILE: crates/vt100-psmux/.cargo_vcs_info.json
================================================
{
"git": {
"sha1": "e79e0d68ab3875f045bc3cc3120907a0e5b3bb0f"
},
"path_in_vcs": ""
}
================================================
FILE: crates/vt100-psmux/CHANGELOG.md
================================================
# Changelog
## [0.16.2] - 2025-07-11
### Fixed
* Fixed potential cursor out of bounds when using decrc after resizing. (#13)
## [0.16.1] - 2025-07-10
### Changed
* Reverted to the 2021 edition for now.
## [0.16.0] - 2025-07-08
### Added
* `Parser::process_cb`, which works the same as `Parser::process` except that
it calls callbacks during parsing when it finds a terminal escape which is
potentially useful but not something that affects the screen itself.
* Support for xterm window resize request escape codes, via the new callback
mechanism.
* Support for dim formatting. (Daniel Faust, #9)
* Support for CNL/CPL escape codes. (Danny Weinberg, #10)
* Support for OSC 52 (clipboard manipulation).
### Removed
* These methods on `Screen` have been removed in favor of the new callback
API described above:
* `title_formatted`
* `title_diff`
* `title`
* `icon_name`
* `bells_diff`
* `audible_bell_count`
* `visual_bell_count`
* `errors`
* Additionally, unhandled escape sequences no longer log to STDERR; they
instead call various callback methods which can be defined to log if
desired.
* `Cell` no longer implements `Default`.
* `Screen` no longer implements `vte::Perform`.
### Changed
* `Parser::set_size` and `Parser::set_scrollback` have been moved to methods
on `Screen`, and `Parser::screen_mut` was added to get a mutable reference
to the screen.
* `Cell::contents` now returns `&str` instead of `String`, eliminating an
allocation in many cases. (Chris Olszewski, #14)
### Fixed
* Fixed some issues with calculating scrollback offsets correctly in
`Grid::visible_rows`. (rezigned, #11)
## [0.15.2] - 2023-02-05
### Changed
* Bumped dependencies
## [0.15.1] - 2021-12-21
### Changed
* Removed a lot of unnecessary test data from the packaged crate, making
downloads faster
## [0.15.0] - 2021-12-15
### Added
* `Screen::errors` to track the number of parsing errors seen so far
### Fixed
* No longer generate spurious diffs in some cases where the cursor is past the
end of a row
* Fix restoring the cursor position when scrolled back
### Changed
* Various internal refactorings
## [0.14.0] - 2021-12-06
### Changed
* Unknown UTF-8 characters default to a width of 1, rather than 0 (except for
control characters, as mentioned below)
### Fixed
* Ignore C1 control characters rather than adding them to the cell data, since
they are non-printable
## [0.13.2] - 2021-12-05
### Changed
* Delay allocation of the alternate screen until it is used (saves a bit of
memory in basic cases)
## [0.13.1] - 2021-12-04
### Fixed
* Fixed various line wrapping state issues
* Fixed cursor positioning after writing zero width characters at the end of
the line
* Fixed `Screen::cursor_state_formatted` to draw the last character in a line
with the appropriate drawing attributes if it needs to redraw it
## [0.13.0] - 2021-11-17
### Added
* `Screen::alternate_screen` to determine if the alternate screen is in use
* `Screen::row_wrapped` to determine whether the row at the given index should
wrap its text
* `Screen::cursor_state_formatted` to set the cursor position and hidden state
(including internal state like the one-past-the-end state which isn't visible
in the return value of `cursor_position`)
### Fixed
* `Screen::rows_formatted` now outputs correct escape codes in some edge cases
at the beginning of a row when the previous row was wrapped
* VPA escape sequence can no longer position the cursor off the screen
## [0.12.0] - 2021-03-09
### Added
* `Screen::state_formatted` and `Screen::state_diff` convenience wrappers
### Fixed
* `Screen::attributes_formatted` now correctly resets previously set attributes
where necessary
### Removed
* Removed `Screen::attributes_diff`, since I can't actually think of any
situation where it does a thing that makes sense.
## [0.11.1] - 2021-03-07
### Changed
* Drop dependency on `enumset`
## [0.11.0] - 2021-03-07
### Added
* `Screen::attributes_formatted` and `Screen::attributes_diff` to retrieve the
current state of the drawing attributes as escape sequences
* `Screen::fgcolor`, `Screen::bgcolor`, `Screen::bold`, `Screen::italic`,
`Screen::underline`, and `Screen::inverse` to retrieve the current state of
the drawing attributes directly
## [0.10.0] - 2021-03-06
### Added
* Implementation of `std::io::Write` for `Parser`
## [0.9.0] - 2021-03-05
### Added
* `Screen::contents_between`, for returning the contents logically between two
given cells (for things like clipboard selection)
* Support SGR subparameters (so `\e[38:2:255:0:0m` behaves the same way as
`\e[38;2;255;0;0m`)
### Fixed
* Bump `enumset` to fix a dependency which fails to build
## [0.8.1] - 2020-02-09
### Changed
* Bumped `vte` dep to 0.6.
## [0.8.0] - 2019-12-07
### Removed
* Removed the unicode-normalization feature altogether - it turns out that it
still has a couple edge cases where it causes incorrect behavior, and fixing
those would be a lot more effort.
### Fixed
* Fix a couple more end-of-line/wrapping bugs, especially around cursor
positioning.
* Fix applying combining characters to wide characters.
* Ensure cells can't have contents with width zero (to avoid ambiguity). If an
empty cell gets a combining character applied to it, default that cell to a
(normal-width) space first.
## [0.7.0] - 2019-11-23
### Added
* New (default-on) cargo feature `unicode-normalization` which can be disabled
to disable normalizing cell contents to NFC - it's a pretty small edge case,
and the data tables required to support it are quite large, which affects
size-sensitive targets like wasm
## [0.6.3] - 2019-11-20
### Fixed
* Fix output of `contents_formatted` and `contents_diff` when the cursor
position ends at one past the end of a row.
* If the cursor position is one past the end of a row, any char, even a
combining char, needs to cause the cursor position to wrap.
## [0.6.2] - 2019-11-13
### Fixed
* Fix zero-width characters when the cursor is at the end of a row.
## [0.6.1] - 2019-11-13
### Added
* Add more debug logging for unhandled escape sequences.
### Changed
* Unhandled escape sequence warnings are now at the `debug` log level.
## [0.6.0] - 2019-11-13
### Added
* `Screen::input_mode_formatted` and `Screen::input_mode_diff` give escape
codes to set the current terminal input modes.
* `Screen::title_formatted` and `Screen::title_diff` give escape codes to set
the terminal window title.
* `Screen::bells_diff` gives escape codes to trigger any audible or visual
bells which have been seen since the previous state.
### Changed
* `Screen::contents_diff` no longer includes audible or visual bells (see
`Screen::bells_diff` instead).
## [0.5.1] - 2019-11-12
### Fixed
* `Screen::set_size` now actually resizes when requested (previously the
underlying storage was not being resized, leading to panics when writing
outside of the original screen).
## [0.5.0] - 2019-11-12
### Added
* Scrollback support.
* `Default` impl for `Parser` which creates an 80x24 terminal with no
scrollback.
### Removed
* `Parser::screen_mut` (and the `pub` `&mut self` methods on `Screen`). The few
things you can do to change the screen state directly are now exposed as
methods on `Parser` itself.
### Changed
* `Cell::contents` now returns a `String` instead of a `&str`.
* `Screen::check_audible_bell` and `Screen::check_visual_bell` have been
replaced with `Screen::audible_bell_count` and `Screen::visual_bell_count`.
You should keep track of the "since the last method call" state yourself
instead of having the screen track it for you.
### Fixed
* Lots of performance and output optimizations.
* Clearing a cell now sets all of that cell's attributes to the current
attribute set, since different terminals render different things for an empty
cell based on the attributes.
* `Screen::contents_diff` now includes audible and visual bells when
appropriate.
## [0.4.0] - 2019-11-08
### Removed
* `Screen::fgcolor`, `Screen::bgcolor`, `Screen::bold`, `Screen::italic`,
`Screen::underline`, `Screen::inverse`, and `Screen::alternate_screen`:
these are just implementation details that people shouldn't need to care
about.
### Fixed
* Fixed cursor movement when the cursor position is already outside of an
active scroll region.
## [0.3.2] - 2019-11-08
### Fixed
* Clearing cells now correctly sets the cell background color.
* Fixed a couple bugs in wide character handling in `contents_formatted` and
`contents_diff`.
* Fixed RI when the cursor is at the top of the screen (fixes scrolling up in
`less`, for instance).
* Fixed VPA incorrectly being clamped to the scroll region.
* Stop treating soft hyphen specially (as far as i can tell, no other terminals
do this, and i'm not sure why i thought it was necessary to begin with).
* `contents_formatted` now also resets attributes at the start, like
`contents_diff` does.
## [0.3.1] - 2019-11-06
### Fixed
* Make `contents_formatted` explicitly show the cursor when necessary, in case
the cursor was previously hidden.
## [0.3.0] - 2019-11-06
### Added
* `Screen::rows` which is like `Screen::contents` except that it returns the
data by row instead of all at once, and also allows you to restrict the
region returned to a subset of columns.
* `Screen::rows_formatted` which is like `Screen::rows`, but returns escape
sequences sufficient to draw the requested subset of each row.
* `Screen::contents_diff` and `Screen::rows_diff` which return escape sequences
sufficient to turn the visible state of one screen (or a subset of the screen
in the case of `rows_diff`) into another.
### Changed
* The screen is now exposed separately from the parser, and is cloneable.
* `contents_formatted` now returns `Vec` instead of `String`.
* `contents` and `contents_formatted` now only allow getting the contents of
the entire screen rather than a subset (but see the entry for `rows` and
`rows_formatted` above).
### Removed
* `Cell::new`, since there's not really any reason that this is useful for
someone to do from outside of the crate.
### Fixed
* `contents_formatted` now preserves the state of empty cells instead of
filling them with spaces.
* We now clear the row wrapping state when the number of columns in the
terminal is changed.
* `contents_formatted` now ensures that the cursor has the correct hidden state
and location.
* `contents_formatted` now clears the screen before starting to draw.
## [0.2.0] - 2019-11-04
### Changed
* Reimplemented in pure safe rust, with a much more accurate parser
* A bunch of minor API tweaks, some backwards-incompatible
## [0.1.2] - 2016-06-04
### Fixed
* Fix returning uninit memory in get_string_formatted/get_string_plaintext
* Handle emoji and zero width unicode characters properly
* Fix cursor positioning with regards to scroll regions and wrapping
* Fix parsing of (ignored) character set escapes
* Explicitly suppress status report escapes
## [0.1.1] - 2016-04-28
### Fixed
* Fix builds
## [0.1.0] - 2016-04-28
### Added
* Initial release
================================================
FILE: crates/vt100-psmux/Cargo.toml
================================================
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.70"
name = "vt100-psmux"
version = "0.16.6"
authors = ["Jesse Luehrs "]
build = false
include = [
"src/**/*",
"LICENSE",
"README.md",
"CHANGELOG.md",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Library for parsing terminal data (psmux fork with blink/hidden/strikethrough SGR + CSI cursor patches)"
homepage = "https://github.com/psmux/vt100-rust-patched"
readme = "README.md"
keywords = [
"terminal",
"vt100",
]
categories = [
"command-line-interface",
"encoding",
]
license = "MIT"
repository = "https://github.com/psmux/vt100-rust-patched"
[lib]
name = "vt100_psmux"
path = "src/lib.rs"
[dependencies.itoa]
version = "1.0.15"
[dependencies.unicode-width]
version = "0.2.1"
[dependencies.vte]
version = "0.15.0"
[dev-dependencies.nix]
version = "0.30.1"
features = ["term"]
[dev-dependencies.quickcheck]
version = "1.0"
[dev-dependencies.rand]
version = "0.10"
[dev-dependencies.serde]
version = "1.0.219"
features = ["derive"]
[dev-dependencies.serde_json]
version = "1.0.140"
[dev-dependencies.terminal_size]
version = "0.4.2"
================================================
FILE: crates/vt100-psmux/Cargo.toml.orig
================================================
[package]
name = "vt100-psmux"
version = "0.16.2"
authors = ["Jesse Luehrs "]
edition = "2021"
rust-version = "1.70"
description = "Library for parsing terminal data (psmux fork with blink/hidden/strikethrough SGR + CSI cursor patches)"
homepage = "https://github.com/marlocarlo/vt100-rust-patched"
repository = "https://github.com/marlocarlo/vt100-rust-patched"
readme = "README.md"
keywords = ["terminal", "vt100"]
categories = ["command-line-interface", "encoding"]
license = "MIT"
include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
[dependencies]
itoa = "1.0.15"
unicode-width = "0.2.1"
vte = "0.15.0"
[dev-dependencies]
nix = { version = "0.30.1", features = ["term"] }
quickcheck = "1.0"
rand = "0.9"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
terminal_size = "0.4.2"
================================================
FILE: crates/vt100-psmux/LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2016 Jesse Luehrs
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: crates/vt100-psmux/README.md
================================================
# vt100
This crate parses a terminal byte stream and provides an in-memory
representation of the rendered contents.
## Overview
This is essentially the terminal parser component of a graphical terminal
emulator pulled out into a separate crate. Although you can use this crate
to build a graphical terminal emulator, it also contains functionality
necessary for implementing terminal applications that want to run other
terminal applications - programs like `screen` or `tmux` for example.
## Synopsis
```rust
let mut parser = vt100::Parser::new(24, 80, 0);
let screen = parser.screen().clone();
parser.process(b"this text is \x1b[31mRED\x1b[m");
assert_eq!(
parser.screen().cell(0, 13).unwrap().fgcolor(),
vt100::Color::Idx(1),
);
let screen = parser.screen().clone();
parser.process(b"\x1b[3D\x1b[32mGREEN");
assert_eq!(
parser.screen().contents_formatted(),
&b"\x1b[?25h\x1b[m\x1b[H\x1b[Jthis text is \x1b[32mGREEN"[..],
);
assert_eq!(
parser.screen().contents_diff(&screen),
&b"\x1b[1;14H\x1b[32mGREEN"[..],
);
```
================================================
FILE: crates/vt100-psmux/src/attrs.rs
================================================
use crate::term::BufWrite as _;
/// Represents a foreground or background color for cells.
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
pub enum Color {
/// The default terminal color.
#[default]
Default,
/// An indexed terminal color.
Idx(u8),
/// An RGB terminal color. The parameters are (red, green, blue).
Rgb(u8, u8, u8),
}
const TEXT_MODE_INTENSITY: u8 = 0b0000_0011;
const TEXT_MODE_BOLD: u8 = 0b0000_0001;
const TEXT_MODE_DIM: u8 = 0b0000_0010;
const TEXT_MODE_ITALIC: u8 = 0b0000_0100;
const TEXT_MODE_UNDERLINE: u8 = 0b0000_1000;
const TEXT_MODE_INVERSE: u8 = 0b0001_0000;
const TEXT_MODE_BLINK: u8 = 0b0010_0000;
const TEXT_MODE_HIDDEN: u8 = 0b0100_0000;
const TEXT_MODE_STRIKETHROUGH: u8 = 0b1000_0000;
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Attrs {
pub fgcolor: Color,
pub bgcolor: Color,
pub mode: u8,
}
impl Attrs {
pub fn bold(&self) -> bool {
self.mode & TEXT_MODE_BOLD != 0
}
pub fn dim(&self) -> bool {
self.mode & TEXT_MODE_DIM != 0
}
fn intensity(&self) -> u8 {
self.mode & TEXT_MODE_INTENSITY
}
pub fn set_bold(&mut self) {
self.mode &= !TEXT_MODE_INTENSITY;
self.mode |= TEXT_MODE_BOLD;
}
pub fn set_dim(&mut self) {
self.mode &= !TEXT_MODE_INTENSITY;
self.mode |= TEXT_MODE_DIM;
}
pub fn set_normal_intensity(&mut self) {
self.mode &= !TEXT_MODE_INTENSITY;
}
pub fn italic(&self) -> bool {
self.mode & TEXT_MODE_ITALIC != 0
}
pub fn set_italic(&mut self, italic: bool) {
if italic {
self.mode |= TEXT_MODE_ITALIC;
} else {
self.mode &= !TEXT_MODE_ITALIC;
}
}
pub fn underline(&self) -> bool {
self.mode & TEXT_MODE_UNDERLINE != 0
}
pub fn set_underline(&mut self, underline: bool) {
if underline {
self.mode |= TEXT_MODE_UNDERLINE;
} else {
self.mode &= !TEXT_MODE_UNDERLINE;
}
}
pub fn inverse(&self) -> bool {
self.mode & TEXT_MODE_INVERSE != 0
}
pub fn set_inverse(&mut self, inverse: bool) {
if inverse {
self.mode |= TEXT_MODE_INVERSE;
} else {
self.mode &= !TEXT_MODE_INVERSE;
}
}
pub fn blink(&self) -> bool {
self.mode & TEXT_MODE_BLINK != 0
}
pub fn set_blink(&mut self, blink: bool) {
if blink {
self.mode |= TEXT_MODE_BLINK;
} else {
self.mode &= !TEXT_MODE_BLINK;
}
}
pub fn hidden(&self) -> bool {
self.mode & TEXT_MODE_HIDDEN != 0
}
pub fn set_hidden(&mut self, hidden: bool) {
if hidden {
self.mode |= TEXT_MODE_HIDDEN;
} else {
self.mode &= !TEXT_MODE_HIDDEN;
}
}
pub fn strikethrough(&self) -> bool {
self.mode & TEXT_MODE_STRIKETHROUGH != 0
}
pub fn set_strikethrough(&mut self, strikethrough: bool) {
if strikethrough {
self.mode |= TEXT_MODE_STRIKETHROUGH;
} else {
self.mode &= !TEXT_MODE_STRIKETHROUGH;
}
}
pub fn write_escape_code_diff(
&self,
contents: &mut Vec,
other: &Self,
) {
if self != other && self == &Self::default() {
crate::term::ClearAttrs.write_buf(contents);
return;
}
let attrs = crate::term::Attrs::default();
let attrs = if self.fgcolor == other.fgcolor {
attrs
} else {
attrs.fgcolor(self.fgcolor)
};
let attrs = if self.bgcolor == other.bgcolor {
attrs
} else {
attrs.bgcolor(self.bgcolor)
};
let attrs = if self.intensity() == other.intensity() {
attrs
} else {
attrs.intensity(match self.intensity() {
0 => crate::term::Intensity::Normal,
TEXT_MODE_BOLD => crate::term::Intensity::Bold,
TEXT_MODE_DIM => crate::term::Intensity::Dim,
_ => unreachable!(),
})
};
let attrs = if self.italic() == other.italic() {
attrs
} else {
attrs.italic(self.italic())
};
let attrs = if self.underline() == other.underline() {
attrs
} else {
attrs.underline(self.underline())
};
let attrs = if self.inverse() == other.inverse() {
attrs
} else {
attrs.inverse(self.inverse())
};
let attrs = if self.blink() == other.blink() {
attrs
} else {
attrs.blink(self.blink())
};
let attrs = if self.hidden() == other.hidden() {
attrs
} else {
attrs.hidden(self.hidden())
};
let attrs = if self.strikethrough() == other.strikethrough() {
attrs
} else {
attrs.strikethrough(self.strikethrough())
};
attrs.write_buf(contents);
}
}
================================================
FILE: crates/vt100-psmux/src/callbacks.rs
================================================
/// This trait is used by the parser to handle extra escape sequences that
/// don't have an impact on the terminal screen directly.
pub trait Callbacks {
/// This callback is called when the terminal requests an audible bell
/// (typically with `^G`).
fn audible_bell(&mut self, _: &mut crate::Screen) {}
/// This callback is called when the terminal requests a visual bell
/// (typically with `\eg`).
fn visual_bell(&mut self, _: &mut crate::Screen) {}
/// This callback is called when the terminal requests a resize
/// (typically with `\e[8;;t`).
fn resize(&mut self, _: &mut crate::Screen, _request: (u16, u16)) {}
/// This callback is called when the terminal requests the window title
/// to be set (typically with `\e]1;\a`)
fn set_window_icon_name(
&mut self,
_: &mut crate::Screen,
_icon_name: &[u8],
) {
}
/// This callback is called when the terminal requests the window title
/// to be set (typically with `\e]2;\a`)
fn set_window_title(&mut self, _: &mut crate::Screen, _title: &[u8]) {}
/// This callback is called when the terminal requests data to be copied
/// to the system clipboard (typically with `\e]52;;\a`). Note
/// that `data` will be encoded as base64.
fn copy_to_clipboard(
&mut self,
_: &mut crate::Screen,
_ty: &[u8],
_data: &[u8],
) {
}
/// This callback is called when the terminal requests data to be pasted
/// from the system clipboard (typically with `\e]52;;?\a`).
fn paste_from_clipboard(&mut self, _: &mut crate::Screen, _ty: &[u8]) {}
/// This callback is called when the terminal receives an escape sequence
/// which is otherwise not implemented.
fn unhandled_char(&mut self, _: &mut crate::Screen, _c: char) {}
/// This callback is called when the terminal receives a control
/// character which is otherwise not implemented.
fn unhandled_control(&mut self, _: &mut crate::Screen, _b: u8) {}
/// This callback is called when the terminal receives an escape sequence
/// which is otherwise not implemented.
fn unhandled_escape(
&mut self,
_: &mut crate::Screen,
_i1: Option,
_i2: Option,
_b: u8,
) {
}
/// This callback is called when the terminal receives a CSI sequence
/// (`\e[`) which is otherwise not implemented.
fn unhandled_csi(
&mut self,
_: &mut crate::Screen,
_i1: Option,
_i2: Option,
_params: &[&[u16]],
_c: char,
) {
}
/// This callback is called when the terminal receives a OSC sequence
/// (`\e]`) which is otherwise not implemented.
fn unhandled_osc(&mut self, _: &mut crate::Screen, _params: &[&[u8]]) {}
/// This callback is called when the terminal receives an OSC 9;4
/// (Windows Terminal progress indicator) sequence. State values:
/// 0 = hide, 1 = default, 2 = error, 3 = indeterminate, 4 = warning.
/// Progress is in 0..=100.
fn set_progress(
&mut self,
_: &mut crate::Screen,
_state: u8,
_progress: u8,
) {
}
}
impl Callbacks for () {}
================================================
FILE: crates/vt100-psmux/src/cell.rs
================================================
use unicode_width::UnicodeWidthChar as _;
// chosen to make the size of the cell struct 32 bytes
const CONTENT_BYTES: usize = 22;
const IS_WIDE: u8 = 0b1000_0000;
const IS_WIDE_CONTINUATION: u8 = 0b0100_0000;
const LEN_BITS: u8 = 0b0001_1111;
/// Represents a single terminal cell.
#[derive(Clone, Debug, Eq)]
pub struct Cell {
contents: [u8; CONTENT_BYTES],
len: u8,
attrs: crate::attrs::Attrs,
}
const _: () = assert!(std::mem::size_of::() == 32);
impl PartialEq for Cell {
fn eq(&self, other: &Self) -> bool {
if self.len != other.len {
return false;
}
if self.attrs != other.attrs {
return false;
}
let len = self.len();
self.contents[..len] == other.contents[..len]
}
}
impl Cell {
pub(crate) fn new() -> Self {
Self {
contents: Default::default(),
len: 0,
attrs: crate::attrs::Attrs::default(),
}
}
fn len(&self) -> usize {
usize::from(self.len & LEN_BITS)
}
pub(crate) fn set(&mut self, c: char, a: crate::attrs::Attrs) {
self.len = 0;
self.append_char(0, c);
// strings in this context should always be an arbitrary character
// followed by zero or more zero-width characters, so we should only
// have to look at the first character
self.set_wide(c.width().unwrap_or(1) > 1);
self.attrs = a;
}
pub(crate) fn append(&mut self, c: char) {
let len = self.len();
if len >= CONTENT_BYTES - 4 {
return;
}
if len == 0 {
self.contents[0] = b' ';
self.len += 1;
}
// we already checked that we have space for another codepoint
self.append_char(self.len(), c);
}
// Writes bytes representing c at start
// Requires caller to verify start <= CODEPOINTS_IN_CELL * 4
fn append_char(&mut self, start: usize, c: char) {
c.encode_utf8(&mut self.contents[start..]);
self.len += u8::try_from(c.len_utf8()).unwrap();
}
pub(crate) fn clear(&mut self, attrs: crate::attrs::Attrs) {
self.len = 0;
self.attrs = attrs;
}
/// Returns the text contents of the cell.
///
/// Can include multiple unicode characters if combining characters are
/// used, but will contain at most one character with a non-zero character
/// width.
// Since contents has been constructed by appending chars encoded as UTF-8 it will be valid UTF-8
#[allow(clippy::missing_panics_doc)]
#[must_use]
pub fn contents(&self) -> &str {
std::str::from_utf8(&self.contents[..self.len()]).unwrap()
}
/// Returns whether the cell contains any text data.
#[must_use]
pub fn has_contents(&self) -> bool {
self.len() > 0
}
/// Returns whether the text data in the cell represents a wide character.
#[must_use]
pub fn is_wide(&self) -> bool {
self.len & IS_WIDE != 0
}
/// Returns whether the cell contains the second half of a wide character
/// (in other words, whether the previous cell in the row contains a wide
/// character)
#[must_use]
pub fn is_wide_continuation(&self) -> bool {
self.len & IS_WIDE_CONTINUATION != 0
}
fn set_wide(&mut self, wide: bool) {
if wide {
self.len |= IS_WIDE;
} else {
self.len &= !IS_WIDE;
}
}
pub(crate) fn set_wide_continuation(&mut self, wide: bool) {
if wide {
self.len |= IS_WIDE_CONTINUATION;
} else {
self.len &= !IS_WIDE_CONTINUATION;
}
}
pub(crate) fn attrs(&self) -> &crate::attrs::Attrs {
&self.attrs
}
/// Returns the foreground color of the cell.
#[must_use]
pub fn fgcolor(&self) -> crate::Color {
self.attrs.fgcolor
}
/// Returns the background color of the cell.
#[must_use]
pub fn bgcolor(&self) -> crate::Color {
self.attrs.bgcolor
}
/// Returns whether the cell should be rendered with the bold text
/// attribute.
#[must_use]
pub fn bold(&self) -> bool {
self.attrs.bold()
}
/// Returns whether the cell should be rendered with the dim text
/// attribute.
#[must_use]
pub fn dim(&self) -> bool {
self.attrs.dim()
}
/// Returns whether the cell should be rendered with the italic text
/// attribute.
#[must_use]
pub fn italic(&self) -> bool {
self.attrs.italic()
}
/// Returns whether the cell should be rendered with the underlined text
/// attribute.
#[must_use]
pub fn underline(&self) -> bool {
self.attrs.underline()
}
/// Returns whether the cell should be rendered with the inverse text
/// attribute.
#[must_use]
pub fn inverse(&self) -> bool {
self.attrs.inverse()
}
/// Returns whether the cell should be rendered with the blink text
/// attribute.
#[must_use]
pub fn blink(&self) -> bool {
self.attrs.blink()
}
/// Returns whether the cell should be rendered with the hidden/invisible
/// text attribute.
#[must_use]
pub fn hidden(&self) -> bool {
self.attrs.hidden()
}
/// Returns whether the cell should be rendered with the strikethrough
/// text attribute.
#[must_use]
pub fn strikethrough(&self) -> bool {
self.attrs.strikethrough()
}
}
================================================
FILE: crates/vt100-psmux/src/grid.rs
================================================
use crate::term::BufWrite as _;
#[derive(Clone, Debug)]
pub struct Grid {
size: Size,
pos: Pos,
saved_pos: Pos,
rows: Vec,
scroll_top: u16,
scroll_bottom: u16,
origin_mode: bool,
saved_origin_mode: bool,
scrollback: std::collections::VecDeque,
scrollback_len: usize,
scrollback_offset: usize,
}
impl Grid {
pub fn new(size: Size, scrollback_len: usize) -> Self {
Self {
size,
pos: Pos::default(),
saved_pos: Pos::default(),
rows: vec![],
scroll_top: 0,
scroll_bottom: size.rows - 1,
origin_mode: false,
saved_origin_mode: false,
scrollback: std::collections::VecDeque::new(),
scrollback_len,
scrollback_offset: 0,
}
}
pub fn allocate_rows(&mut self) {
if self.rows.is_empty() {
self.rows.extend(
std::iter::repeat_with(|| {
crate::row::Row::new(self.size.cols)
})
.take(usize::from(self.size.rows)),
);
}
}
fn new_row(&self) -> crate::row::Row {
crate::row::Row::new(self.size.cols)
}
pub fn clear(&mut self) {
self.pos = Pos::default();
self.saved_pos = Pos::default();
for row in self.drawing_rows_mut() {
row.clear(crate::attrs::Attrs::default());
}
self.scroll_top = 0;
self.scroll_bottom = self.size.rows - 1;
self.origin_mode = false;
self.saved_origin_mode = false;
}
pub fn size(&self) -> Size {
self.size
}
pub fn set_size(&mut self, size: Size) {
if size.cols != self.size.cols {
for row in &mut self.rows {
row.wrap(false);
}
}
if self.scroll_bottom == self.size.rows - 1 {
self.scroll_bottom = size.rows - 1;
}
self.size = size;
for row in &mut self.rows {
row.resize(size.cols, crate::Cell::new());
}
self.rows.resize(usize::from(size.rows), self.new_row());
if self.scroll_bottom >= size.rows {
self.scroll_bottom = size.rows - 1;
}
if self.scroll_bottom < self.scroll_top {
self.scroll_top = 0;
}
self.row_clamp_top(false);
self.row_clamp_bottom(false);
self.col_clamp();
if self.saved_pos.row > self.size.rows - 1 {
self.saved_pos.row = self.size.rows - 1;
}
if self.saved_pos.col > self.size.cols - 1 {
self.saved_pos.col = self.size.cols - 1;
}
}
pub fn pos(&self) -> Pos {
self.pos
}
pub fn set_pos(&mut self, mut pos: Pos) {
if self.origin_mode {
pos.row = pos.row.saturating_add(self.scroll_top);
}
self.pos = pos;
self.row_clamp_top(self.origin_mode);
self.row_clamp_bottom(self.origin_mode);
self.col_clamp();
}
pub fn save_cursor(&mut self) {
self.saved_pos = self.pos;
self.saved_origin_mode = self.origin_mode;
}
pub fn restore_cursor(&mut self) {
self.pos = self.saved_pos;
self.origin_mode = self.saved_origin_mode;
}
pub fn visible_rows(&self) -> impl Iterator- {
let scrollback_len = self.scrollback.len();
let rows_len = self.rows.len();
self.scrollback
.iter()
.skip(scrollback_len - self.scrollback_offset)
// when scrollback_offset > rows_len (e.g. rows = 3,
// scrollback_len = 10, offset = 9) the skip(10 - 9)
// will take 9 rows instead of 3. we need to set
// the upper bound to rows_len (e.g. 3)
.take(rows_len)
// same for rows_len - scrollback_offset (e.g. 3 - 9).
// it'll panic with overflow. we have to saturate the subtraction.
.chain(
self.rows
.iter()
.take(rows_len.saturating_sub(self.scrollback_offset)),
)
}
pub fn drawing_rows(&self) -> impl Iterator
- {
self.rows.iter()
}
pub fn drawing_rows_mut(
&mut self,
) -> impl Iterator
- {
self.rows.iter_mut()
}
pub fn visible_row(&self, row: u16) -> Option<&crate::row::Row> {
self.visible_rows().nth(usize::from(row))
}
pub fn drawing_row(&self, row: u16) -> Option<&crate::row::Row> {
self.drawing_rows().nth(usize::from(row))
}
pub fn drawing_row_mut(
&mut self,
row: u16,
) -> Option<&mut crate::row::Row> {
self.drawing_rows_mut().nth(usize::from(row))
}
pub fn current_row_mut(&mut self) -> &mut crate::row::Row {
self.drawing_row_mut(self.pos.row)
// we assume self.pos.row is always valid
.unwrap()
}
pub fn visible_cell(&self, pos: Pos) -> Option<&crate::Cell> {
self.visible_row(pos.row).and_then(|r| r.get(pos.col))
}
pub fn drawing_cell(&self, pos: Pos) -> Option<&crate::Cell> {
self.drawing_row(pos.row).and_then(|r| r.get(pos.col))
}
pub fn drawing_cell_mut(&mut self, pos: Pos) -> Option<&mut crate::Cell> {
self.drawing_row_mut(pos.row)
.and_then(|r| r.get_mut(pos.col))
}
pub fn scrollback_len(&self) -> usize {
self.scrollback_len
}
pub fn scrollback(&self) -> usize {
self.scrollback_offset
}
pub fn set_scrollback(&mut self, rows: usize) {
self.scrollback_offset = rows.min(self.scrollback.len());
}
/// Returns the number of rows currently held in the scrollback buffer
/// (distinct from `scrollback_len`, which is the configured maximum).
pub fn scrollback_filled(&self) -> usize {
self.scrollback.len()
}
/// Updates the scrollback buffer's maximum size. When `new_len` is
/// smaller than the current fill, the oldest rows are trimmed away.
pub fn set_scrollback_len(&mut self, new_len: usize) {
self.scrollback_len = new_len;
while self.scrollback.len() > self.scrollback_len {
self.scrollback.pop_front();
}
if self.scrollback_offset > self.scrollback.len() {
self.scrollback_offset = self.scrollback.len();
}
}
/// Append a row to the back of scrollback, evicting the oldest if
/// the cap is reached. Used by the alt-screen-to-scrollback copy
/// path (psmux issue #88). Honours `scrollback_len = 0` (no-op),
/// matching how the normal in-flow scrolling treats that case.
pub fn push_row_to_scrollback(&mut self, row: crate::row::Row) {
if self.scrollback_len == 0 {
return;
}
self.scrollback.push_back(row);
while self.scrollback.len() > self.scrollback_len {
self.scrollback.pop_front();
}
if self.scrollback_offset > 0 {
self.scrollback_offset =
self.scrollback.len().min(self.scrollback_offset + 1);
}
}
pub fn write_contents(&self, contents: &mut String) {
let mut wrapping = false;
for row in self.visible_rows() {
row.write_contents(contents, 0, self.size.cols, wrapping);
if !row.wrapped() {
contents.push('\n');
}
wrapping = row.wrapped();
}
while contents.ends_with('\n') {
contents.truncate(contents.len() - 1);
}
}
pub fn write_contents_formatted(
&self,
contents: &mut Vec
,
) -> crate::attrs::Attrs {
crate::term::ClearAttrs.write_buf(contents);
crate::term::ClearScreen.write_buf(contents);
let mut prev_attrs = crate::attrs::Attrs::default();
let mut prev_pos = Pos::default();
let mut wrapping = false;
for (i, row) in self.visible_rows().enumerate() {
// we limit the number of cols to a u16 (see Size), so
// visible_rows() can never return more rows than will fit
let i = i.try_into().unwrap();
let (new_pos, new_attrs) = row.write_contents_formatted(
contents,
0,
self.size.cols,
i,
wrapping,
Some(prev_pos),
Some(prev_attrs),
);
prev_pos = new_pos;
prev_attrs = new_attrs;
wrapping = row.wrapped();
}
self.write_cursor_position_formatted(
contents,
Some(prev_pos),
Some(prev_attrs),
);
prev_attrs
}
pub fn write_contents_diff(
&self,
contents: &mut Vec,
prev: &Self,
mut prev_attrs: crate::attrs::Attrs,
) -> crate::attrs::Attrs {
let mut prev_pos = prev.pos;
let mut wrapping = false;
let mut prev_wrapping = false;
for (i, (row, prev_row)) in
self.visible_rows().zip(prev.visible_rows()).enumerate()
{
// we limit the number of cols to a u16 (see Size), so
// visible_rows() can never return more rows than will fit
let i = i.try_into().unwrap();
let (new_pos, new_attrs) = row.write_contents_diff(
contents,
prev_row,
0,
self.size.cols,
i,
wrapping,
prev_wrapping,
prev_pos,
prev_attrs,
);
prev_pos = new_pos;
prev_attrs = new_attrs;
wrapping = row.wrapped();
prev_wrapping = prev_row.wrapped();
}
self.write_cursor_position_formatted(
contents,
Some(prev_pos),
Some(prev_attrs),
);
prev_attrs
}
pub fn write_cursor_position_formatted(
&self,
contents: &mut Vec,
prev_pos: Option,
prev_attrs: Option,
) {
let prev_attrs = prev_attrs.unwrap_or_default();
// writing a character to the last column of a row doesn't wrap the
// cursor immediately - it waits until the next character is actually
// drawn. it is only possible for the cursor to have this kind of
// position after drawing a character though, so if we end in this
// position, we need to redraw the character at the end of the row.
if prev_pos != Some(self.pos) && self.pos.col >= self.size.cols {
let mut pos = Pos {
row: self.pos.row,
col: self.size.cols - 1,
};
if self
.drawing_cell(pos)
// we assume self.pos.row is always valid, and self.size.cols
// - 1 is always a valid column
.unwrap()
.is_wide_continuation()
{
pos.col = self.size.cols - 2;
}
let cell =
// we assume self.pos.row is always valid, and self.size.cols
// - 2 must be a valid column because self.size.cols - 1 is
// always valid and we just checked that the cell at
// self.size.cols - 1 is a wide continuation character, which
// means that the first half of the wide character must be
// before it
self.drawing_cell(pos).unwrap();
if cell.has_contents() {
if let Some(prev_pos) = prev_pos {
crate::term::MoveFromTo::new(prev_pos, pos)
.write_buf(contents);
} else {
crate::term::MoveTo::new(pos).write_buf(contents);
}
cell.attrs().write_escape_code_diff(contents, &prev_attrs);
contents.extend(cell.contents().as_bytes());
prev_attrs.write_escape_code_diff(contents, cell.attrs());
} else {
// if the cell doesn't have contents, we can't have gotten
// here by drawing a character in the last column. this means
// that as far as i'm aware, we have to have reached here from
// a newline when we were already after the end of an earlier
// row. in the case where we are already after the end of an
// earlier row, we can just write a few newlines, otherwise we
// also need to do the same as above to get ourselves to after
// the end of a row.
let mut found = false;
for i in (0..self.pos.row).rev() {
pos.row = i;
pos.col = self.size.cols - 1;
if self
.drawing_cell(pos)
// i is always less than self.pos.row, which we assume
// to be always valid, so it must also be valid.
// self.size.cols - 1 is always a valid col.
.unwrap()
.is_wide_continuation()
{
pos.col = self.size.cols - 2;
}
let cell = self
.drawing_cell(pos)
// i is always less than self.pos.row, which we assume
// to be always valid, so it must also be valid.
// self.size.cols - 2 is valid because self.size.cols
// - 1 is always valid, and col gets set to
// self.size.cols - 2 when the cell at self.size.cols
// - 1 is a wide continuation character, meaning that
// the first half of the wide character must be before
// it
.unwrap();
if cell.has_contents() {
if let Some(prev_pos) = prev_pos {
if prev_pos.row != i
|| prev_pos.col < self.size.cols
{
crate::term::MoveFromTo::new(prev_pos, pos)
.write_buf(contents);
cell.attrs().write_escape_code_diff(
contents,
&prev_attrs,
);
contents.extend(cell.contents().as_bytes());
prev_attrs.write_escape_code_diff(
contents,
cell.attrs(),
);
}
} else {
crate::term::MoveTo::new(pos).write_buf(contents);
cell.attrs().write_escape_code_diff(
contents,
&prev_attrs,
);
contents.extend(cell.contents().as_bytes());
prev_attrs.write_escape_code_diff(
contents,
cell.attrs(),
);
}
contents.extend(
"\n".repeat(usize::from(self.pos.row - i))
.as_bytes(),
);
found = true;
break;
}
}
// this can happen if you get the cursor off the end of a row,
// and then do something to clear the end of the current row
// without moving the cursor (IL, DL, ED, EL, etc). we know
// there can't be something in the last column because we
// would have caught that above, so it should be safe to
// overwrite it.
if !found {
pos = Pos {
row: self.pos.row,
col: self.size.cols - 1,
};
if let Some(prev_pos) = prev_pos {
crate::term::MoveFromTo::new(prev_pos, pos)
.write_buf(contents);
} else {
crate::term::MoveTo::new(pos).write_buf(contents);
}
contents.push(b' ');
// we know that the cell has no contents, but it still may
// have drawing attributes (background color, etc)
let end_cell = self
.drawing_cell(pos)
// we assume self.pos.row is always valid, and
// self.size.cols - 1 is always a valid column
.unwrap();
end_cell
.attrs()
.write_escape_code_diff(contents, &prev_attrs);
crate::term::SaveCursor.write_buf(contents);
crate::term::Backspace.write_buf(contents);
crate::term::EraseChar::new(1).write_buf(contents);
crate::term::RestoreCursor.write_buf(contents);
prev_attrs
.write_escape_code_diff(contents, end_cell.attrs());
}
}
} else if let Some(prev_pos) = prev_pos {
crate::term::MoveFromTo::new(prev_pos, self.pos)
.write_buf(contents);
} else {
crate::term::MoveTo::new(self.pos).write_buf(contents);
}
}
pub fn clear_scrollback(&mut self) {
self.scrollback.clear();
self.scrollback_offset = 0;
}
pub fn erase_all(&mut self, attrs: crate::attrs::Attrs) {
for row in self.drawing_rows_mut() {
row.clear(attrs);
}
}
pub fn erase_all_forward(&mut self, attrs: crate::attrs::Attrs) {
let pos = self.pos;
for row in self.drawing_rows_mut().skip(usize::from(pos.row) + 1) {
row.clear(attrs);
}
self.erase_row_forward(attrs);
}
pub fn erase_all_backward(&mut self, attrs: crate::attrs::Attrs) {
let pos = self.pos;
for row in self.drawing_rows_mut().take(usize::from(pos.row)) {
row.clear(attrs);
}
self.erase_row_backward(attrs);
}
pub fn erase_row(&mut self, attrs: crate::attrs::Attrs) {
self.current_row_mut().clear(attrs);
}
pub fn erase_row_forward(&mut self, attrs: crate::attrs::Attrs) {
let size = self.size;
let pos = self.pos;
let row = self.current_row_mut();
for col in pos.col..size.cols {
row.erase(col, attrs);
}
}
pub fn erase_row_backward(&mut self, attrs: crate::attrs::Attrs) {
let size = self.size;
let pos = self.pos;
let row = self.current_row_mut();
for col in 0..=pos.col.min(size.cols - 1) {
row.erase(col, attrs);
}
}
pub fn insert_cells(&mut self, count: u16) {
let size = self.size;
let pos = self.pos;
let wide = pos.col < size.cols
&& self
.drawing_cell(pos)
// we assume self.pos.row is always valid, and we know we are
// not off the end of a row because we just checked pos.col <
// size.cols
.unwrap()
.is_wide_continuation();
let row = self.current_row_mut();
for _ in 0..count {
if wide {
row.get_mut(pos.col).unwrap().set_wide_continuation(false);
}
row.insert(pos.col, crate::Cell::new());
if wide {
row.get_mut(pos.col).unwrap().set_wide_continuation(true);
}
}
row.truncate(size.cols);
}
pub fn delete_cells(&mut self, count: u16) {
let size = self.size;
let pos = self.pos;
let row = self.current_row_mut();
for _ in 0..(count.min(size.cols - pos.col)) {
row.remove(pos.col);
}
row.resize(size.cols, crate::Cell::new());
}
pub fn erase_cells(&mut self, count: u16, attrs: crate::attrs::Attrs) {
let size = self.size;
let pos = self.pos;
let row = self.current_row_mut();
for col in pos.col..((pos.col.saturating_add(count)).min(size.cols)) {
row.erase(col, attrs);
}
}
pub fn insert_lines(&mut self, count: u16) {
for _ in 0..count {
self.rows.remove(usize::from(self.scroll_bottom));
self.rows.insert(usize::from(self.pos.row), self.new_row());
// self.scroll_bottom is maintained to always be a valid row
self.rows[usize::from(self.scroll_bottom)].wrap(false);
}
}
pub fn delete_lines(&mut self, count: u16) {
for _ in 0..(count.min(self.size.rows - self.pos.row)) {
self.rows
.insert(usize::from(self.scroll_bottom) + 1, self.new_row());
self.rows.remove(usize::from(self.pos.row));
}
}
pub fn scroll_up(&mut self, count: u16) {
for _ in 0..(count.min(self.size.rows - self.scroll_top)) {
self.rows
.insert(usize::from(self.scroll_bottom) + 1, self.new_row());
let removed = self.rows.remove(usize::from(self.scroll_top));
if self.scrollback_len > 0 && !self.scroll_region_active() {
self.scrollback.push_back(removed);
while self.scrollback.len() > self.scrollback_len {
self.scrollback.pop_front();
}
if self.scrollback_offset > 0 {
self.scrollback_offset =
self.scrollback.len().min(self.scrollback_offset + 1);
}
}
}
}
pub fn scroll_down(&mut self, count: u16) {
for _ in 0..count {
self.rows.remove(usize::from(self.scroll_bottom));
self.rows
.insert(usize::from(self.scroll_top), self.new_row());
// self.scroll_bottom is maintained to always be a valid row
self.rows[usize::from(self.scroll_bottom)].wrap(false);
}
}
pub fn set_scroll_region(&mut self, top: u16, bottom: u16) {
let bottom = bottom.min(self.size().rows - 1);
if top < bottom {
self.scroll_top = top;
self.scroll_bottom = bottom;
} else {
self.scroll_top = 0;
self.scroll_bottom = self.size().rows - 1;
}
self.pos.row = self.scroll_top;
self.pos.col = 0;
}
fn in_scroll_region(&self) -> bool {
self.pos.row >= self.scroll_top && self.pos.row <= self.scroll_bottom
}
fn scroll_region_active(&self) -> bool {
self.scroll_top != 0 || self.scroll_bottom != self.size.rows - 1
}
pub fn set_origin_mode(&mut self, mode: bool) {
self.origin_mode = mode;
self.set_pos(Pos { row: 0, col: 0 });
}
pub fn row_inc_clamp(&mut self, count: u16) {
let in_scroll_region = self.in_scroll_region();
self.pos.row = self.pos.row.saturating_add(count);
self.row_clamp_bottom(in_scroll_region);
}
pub fn row_inc_scroll(&mut self, count: u16) -> u16 {
let in_scroll_region = self.in_scroll_region();
self.pos.row = self.pos.row.saturating_add(count);
let lines = self.row_clamp_bottom(in_scroll_region);
if in_scroll_region {
self.scroll_up(lines);
lines
} else {
0
}
}
pub fn row_dec_clamp(&mut self, count: u16) {
let in_scroll_region = self.in_scroll_region();
self.pos.row = self.pos.row.saturating_sub(count);
self.row_clamp_top(in_scroll_region);
}
pub fn row_dec_scroll(&mut self, count: u16) {
let in_scroll_region = self.in_scroll_region();
// need to account for clamping by both row_clamp_top and by
// saturating_sub
let extra_lines = count.saturating_sub(self.pos.row);
self.pos.row = self.pos.row.saturating_sub(count);
let lines = self.row_clamp_top(in_scroll_region);
self.scroll_down(lines + extra_lines);
}
pub fn row_set(&mut self, i: u16) {
self.pos.row = i;
self.row_clamp();
}
pub fn col_inc(&mut self, count: u16) {
self.pos.col = self.pos.col.saturating_add(count);
}
pub fn col_inc_clamp(&mut self, count: u16) {
self.pos.col = self.pos.col.saturating_add(count);
self.col_clamp();
}
pub fn col_dec(&mut self, count: u16) {
self.pos.col = self.pos.col.saturating_sub(count);
}
pub fn col_tab(&mut self) {
self.pos.col -= self.pos.col % 8;
self.pos.col += 8;
self.col_clamp();
}
pub fn col_set(&mut self, i: u16) {
self.pos.col = i;
self.col_clamp();
}
pub fn col_wrap(&mut self, width: u16, wrap: bool) {
if self.pos.col > self.size.cols - width {
let mut prev_pos = self.pos;
self.pos.col = 0;
let scrolled = self.row_inc_scroll(1);
prev_pos.row -= scrolled;
let new_pos = self.pos;
self.drawing_row_mut(prev_pos.row)
// we assume self.pos.row is always valid, and so prev_pos.row
// must be valid because it is always less than or equal to
// self.pos.row
.unwrap()
.wrap(wrap && prev_pos.row + 1 == new_pos.row);
}
}
fn row_clamp_top(&mut self, limit_to_scroll_region: bool) -> u16 {
if limit_to_scroll_region && self.pos.row < self.scroll_top {
let rows = self.scroll_top - self.pos.row;
self.pos.row = self.scroll_top;
rows
} else {
0
}
}
fn row_clamp_bottom(&mut self, limit_to_scroll_region: bool) -> u16 {
let bottom = if limit_to_scroll_region {
self.scroll_bottom
} else {
self.size.rows - 1
};
if self.pos.row > bottom {
let rows = self.pos.row - bottom;
self.pos.row = bottom;
rows
} else {
0
}
}
fn row_clamp(&mut self) {
if self.pos.row > self.size.rows - 1 {
self.pos.row = self.size.rows - 1;
}
}
fn col_clamp(&mut self) {
if self.pos.col > self.size.cols - 1 {
self.pos.col = self.size.cols - 1;
}
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct Size {
pub rows: u16,
pub cols: u16,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct Pos {
pub row: u16,
pub col: u16,
}
================================================
FILE: crates/vt100-psmux/src/lib.rs
================================================
#![allow(dead_code)]
//! This crate parses a terminal byte stream and provides an in-memory
//! representation of the rendered contents.
//!
//! # Overview
//!
//! This is essentially the terminal parser component of a graphical terminal
//! emulator pulled out into a separate crate. Although you can use this crate
//! to build a graphical terminal emulator, it also contains functionality
//! necessary for implementing terminal applications that want to run other
//! terminal applications - programs like `screen` or `tmux` for example.
//!
//! # Synopsis
//!
//! ```
//! # use vt100_psmux as vt100;
//! let mut parser = vt100::Parser::new(24, 80, 0);
//!
//! let screen = parser.screen().clone();
//! parser.process(b"this text is \x1b[31mRED\x1b[m");
//! assert_eq!(
//! parser.screen().cell(0, 13).unwrap().fgcolor(),
//! vt100::Color::Idx(1),
//! );
//!
//! let screen = parser.screen().clone();
//! parser.process(b"\x1b[3D\x1b[32mGREEN");
//! assert_eq!(
//! parser.screen().contents_formatted(),
//! &b"\x1b[?25h\x1b[m\x1b[H\x1b[Jthis text is \x1b[32mGREEN"[..],
//! );
//! assert_eq!(
//! parser.screen().contents_diff(&screen),
//! &b"\x1b[1;14H\x1b[32mGREEN"[..],
//! );
//! ```
#![warn(missing_docs)]
#![warn(clippy::cargo)]
#![warn(clippy::pedantic)]
#![warn(clippy::nursery)]
#![warn(clippy::as_conversions)]
#![warn(clippy::get_unwrap)]
#![allow(clippy::cognitive_complexity)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::similar_names)]
#![allow(clippy::struct_excessive_bools)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::type_complexity)]
#[cfg(test)]
extern crate self as vt100;
mod attrs;
mod callbacks;
mod cell;
mod grid;
mod parser;
mod perform;
mod row;
mod screen;
mod term;
pub use attrs::Color;
pub use callbacks::Callbacks;
pub use cell::Cell;
pub use parser::Parser;
pub use screen::{MouseProtocolEncoding, MouseProtocolMode, Screen};
================================================
FILE: crates/vt100-psmux/src/parser.rs
================================================
/// A parser for terminal output which produces an in-memory representation of
/// the terminal contents.
pub struct Parser {
parser: vte::Parser,
screen: crate::perform::WrappedScreen,
}
impl Parser {
/// Creates a new terminal parser of the given size and with the given
/// amount of scrollback.
#[must_use]
pub fn new(rows: u16, cols: u16, scrollback_len: usize) -> Self {
Self {
parser: vte::Parser::new(),
screen: crate::perform::WrappedScreen::new(
rows,
cols,
scrollback_len,
),
}
}
}
impl Parser {
/// Creates a new terminal parser of the given size and with the given
/// amount of scrollback. Terminal events will be reported via method
/// calls on the provided [`Callbacks`](crate::callbacks::Callbacks)
/// implementation.
pub fn new_with_callbacks(
rows: u16,
cols: u16,
scrollback_len: usize,
callbacks: CB,
) -> Self {
Self {
parser: vte::Parser::new(),
screen: crate::perform::WrappedScreen::new_with_callbacks(
rows,
cols,
scrollback_len,
callbacks,
),
}
}
/// Processes the contents of the given byte string, and updates the
/// in-memory terminal state.
pub fn process(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.screen, bytes);
}
/// Returns a reference to a [`Screen`](crate::Screen) object containing
/// the terminal state.
#[must_use]
pub fn screen(&self) -> &crate::Screen {
&self.screen.screen
}
/// Returns a mutable reference to a [`Screen`](crate::Screen) object
/// containing the terminal state.
#[must_use]
pub fn screen_mut(&mut self) -> &mut crate::Screen {
&mut self.screen.screen
}
/// Returns a reference to the [`Callbacks`](crate::callbacks::Callbacks)
/// state object passed into the constructor.
pub fn callbacks(&self) -> &CB {
&self.screen.callbacks
}
/// Returns a mutable reference to the
/// [`Callbacks`](crate::callbacks::Callbacks) state object passed into
/// the constructor.
pub fn callbacks_mut(&mut self) -> &mut CB {
&mut self.screen.callbacks
}
}
impl Default for Parser {
/// Returns a parser with dimensions 80x24 and no scrollback.
fn default() -> Self {
Self::new(24, 80, 0)
}
}
impl std::io::Write for Parser {
fn write(&mut self, buf: &[u8]) -> std::io::Result {
self.process(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
================================================
FILE: crates/vt100-psmux/src/perform.rs
================================================
const BASE64: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
const CLIPBOARD_SELECTOR: &[u8] = b"cpqs01234567";
pub struct WrappedScreen {
pub screen: crate::screen::Screen,
pub callbacks: CB,
}
impl WrappedScreen<()> {
pub fn new(rows: u16, cols: u16, scrollback_len: usize) -> Self {
Self::new_with_callbacks(rows, cols, scrollback_len, ())
}
}
impl WrappedScreen {
pub fn new_with_callbacks(
rows: u16,
cols: u16,
scrollback_len: usize,
callbacks: CB,
) -> Self {
Self {
screen: crate::screen::Screen::new(
crate::grid::Size { rows, cols },
scrollback_len,
),
callbacks,
}
}
}
impl vte::Perform for WrappedScreen {
fn print(&mut self, c: char) {
if c == '\u{fffd}' || ('\u{80}'..'\u{a0}').contains(&c) {
self.callbacks.unhandled_char(&mut self.screen, c);
} else {
self.screen.text(c);
}
}
fn execute(&mut self, b: u8) {
match b {
7 => {
self.screen.audible_bell_count = self.screen.audible_bell_count.wrapping_add(1);
self.callbacks.audible_bell(&mut self.screen);
}
8 => self.screen.bs(),
9 => self.screen.tab(),
10 => self.screen.lf(),
11 => self.screen.vt(),
12 => self.screen.ff(),
13 => self.screen.cr(),
// we don't implement shift in/out alternate character sets, but
// it shouldn't count as an "error"
14 | 15 => {}
_ => self.callbacks.unhandled_control(&mut self.screen, b),
}
}
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, b: u8) {
if let Some(i) = intermediates.first() {
self.callbacks.unhandled_escape(
&mut self.screen,
Some(*i),
intermediates.get(1).copied(),
b,
);
} else {
match b {
b'7' => self.screen.decsc(),
b'8' => self.screen.decrc(),
b'=' => self.screen.deckpam(),
b'>' => self.screen.deckpnm(),
b'M' => self.screen.ri(),
b'c' => self.screen.ris(),
b'g' => self.callbacks.visual_bell(&mut self.screen),
_ => {
self.callbacks.unhandled_escape(
&mut self.screen,
None,
None,
b,
);
}
}
}
}
fn csi_dispatch(
&mut self,
params: &vte::Params,
intermediates: &[u8],
_ignore: bool,
c: char,
) {
let unhandled = |screen: &mut crate::screen::Screen| {
self.callbacks.unhandled_csi(
screen,
intermediates.first().copied(),
intermediates.get(1).copied(),
¶ms.iter().collect::>(),
c,
);
};
match intermediates.first() {
None => match c {
'@' => self.screen.ich(canonicalize_params_1(params, 1)),
'A' => self.screen.cuu(canonicalize_params_1(params, 1)),
'B' => self.screen.cud(canonicalize_params_1(params, 1)),
'C' => self.screen.cuf(canonicalize_params_1(params, 1)),
'D' => self.screen.cub(canonicalize_params_1(params, 1)),
'E' => self.screen.cnl(canonicalize_params_1(params, 1)),
'F' => self.screen.cpl(canonicalize_params_1(params, 1)),
'G' => self.screen.cha(canonicalize_params_1(params, 1)),
'H' | 'f' => self.screen.cup(canonicalize_params_2(params, 1, 1)),
'J' => self
.screen
.ed(canonicalize_params_1(params, 0), unhandled),
'K' => self
.screen
.el(canonicalize_params_1(params, 0), unhandled),
'L' => self.screen.il(canonicalize_params_1(params, 1)),
'M' => self.screen.dl(canonicalize_params_1(params, 1)),
'P' => self.screen.dch(canonicalize_params_1(params, 1)),
'S' => self.screen.su(canonicalize_params_1(params, 1)),
'T' => self.screen.sd(canonicalize_params_1(params, 1)),
'X' => self.screen.ech(canonicalize_params_1(params, 1)),
'd' => self.screen.vpa(canonicalize_params_1(params, 1)),
'm' => self.screen.sgr(params, unhandled),
'n' => {
// DSR (Device Status Report) — in passthrough mode the
// child sends this and expects a response. We ignore it
// at the parser level (the host must respond via the PTY
// writer if needed), but we must not call unhandled.
}
'r' => self.screen.decstbm(canonicalize_params_decstbm(
params,
self.screen.grid().size(),
)),
's' => self.screen.decsc(),
'u' => self.screen.decrc(),
't' => {
let mut params_iter = params.iter();
let op =
params_iter.next().and_then(|x| x.first().copied());
if op == Some(8) {
let (screen_rows, screen_cols) = self.screen.size();
let rows =
params_iter.next().map_or(screen_rows, |x| {
*x.first().unwrap_or(&screen_rows)
});
let cols =
params_iter.next().map_or(screen_cols, |x| {
*x.first().unwrap_or(&screen_cols)
});
self.callbacks.resize(&mut self.screen, (rows, cols));
} else {
self.callbacks.unhandled_csi(
&mut self.screen,
None,
None,
¶ms.iter().collect::>(),
c,
);
}
}
_ => {
self.callbacks.unhandled_csi(
&mut self.screen,
None,
None,
¶ms.iter().collect::>(),
c,
);
}
},
Some(b'?') => match c {
'J' => self
.screen
.decsed(canonicalize_params_1(params, 0), unhandled),
'K' => self
.screen
.decsel(canonicalize_params_1(params, 0), unhandled),
'h' => self.screen.decset(params, unhandled),
'l' => self.screen.decrst(params, unhandled),
_ => {
self.callbacks.unhandled_csi(
&mut self.screen,
Some(b'?'),
intermediates.get(1).copied(),
¶ms.iter().collect::>(),
c,
);
}
},
Some(i) => {
self.callbacks.unhandled_csi(
&mut self.screen,
Some(*i),
intermediates.get(1).copied(),
¶ms.iter().collect::>(),
c,
);
}
}
}
fn osc_dispatch(&mut self, params: &[&[u8]], _bel_terminated: bool) {
match params {
[b"0", s] => {
self.callbacks.set_window_icon_name(&mut self.screen, s);
self.callbacks.set_window_title(&mut self.screen, s);
self.screen.set_title(s);
}
[b"1", s] => {
self.callbacks.set_window_icon_name(&mut self.screen, s);
}
[b"2", s] => {
self.callbacks.set_window_title(&mut self.screen, s);
self.screen.set_title(s);
}
[b"7", uri] => {
self.screen.set_path(uri);
}
[b"9", b"4", state, progress] => {
// OSC 9;4 — Windows Terminal progress indicator.
// state: 0=hide, 1=default, 2=error, 3=indeterminate, 4=warning
// progress: 0..=100
let s = std::str::from_utf8(state)
.ok()
.and_then(|s| s.parse::().ok())
.unwrap_or(0);
let v = std::str::from_utf8(progress)
.ok()
.and_then(|s| s.parse::().ok())
.unwrap_or(0);
self.screen.set_progress(s, v);
self.callbacks.set_progress(&mut self.screen, s, v);
}
[b"9999", ..] => {
self.screen.squelch_cleared = true;
}
[b"52", ty, data] => {
match (
ty.iter().all(|c| CLIPBOARD_SELECTOR.contains(c)),
*data,
) {
(true, b"?") => {
self.callbacks
.paste_from_clipboard(&mut self.screen, ty);
}
(true, data)
if data.iter().all(|c| BASE64.contains(c)) =>
{
// Stage the payload on Screen so the psmux server
// can drain it and forward an OSC 52 to the host
// terminal. Unblocks tools like Claude Code's
// `/copy` running inside a pane (OSC 52 was being
// swallowed by the default no-op callbacks).
self.screen.set_clipboard(ty, data);
self.callbacks.copy_to_clipboard(
&mut self.screen,
ty,
data,
);
}
_ => {
self.callbacks
.unhandled_osc(&mut self.screen, params);
}
}
}
_ => {
self.callbacks.unhandled_osc(&mut self.screen, params);
}
}
}
}
fn canonicalize_params_1(params: &vte::Params, default: u16) -> u16 {
let first = params.iter().next().map_or(0, |x| *x.first().unwrap_or(&0));
if first == 0 {
default
} else {
first
}
}
fn canonicalize_params_2(
params: &vte::Params,
default1: u16,
default2: u16,
) -> (u16, u16) {
let mut iter = params.iter();
let first = iter.next().map_or(0, |x| *x.first().unwrap_or(&0));
let first = if first == 0 { default1 } else { first };
let second = iter.next().map_or(0, |x| *x.first().unwrap_or(&0));
let second = if second == 0 { default2 } else { second };
(first, second)
}
fn canonicalize_params_decstbm(
params: &vte::Params,
size: crate::grid::Size,
) -> (u16, u16) {
let mut iter = params.iter();
let top = iter.next().map_or(0, |x| *x.first().unwrap_or(&0));
let top = if top == 0 { 1 } else { top };
let bottom = iter.next().map_or(0, |x| *x.first().unwrap_or(&0));
let bottom = if bottom == 0 { size.rows } else { bottom };
(top, bottom)
}
================================================
FILE: crates/vt100-psmux/src/row.rs
================================================
use crate::term::BufWrite as _;
#[derive(Clone, Debug)]
pub struct Row {
cells: Vec,
wrapped: bool,
}
impl Row {
pub fn new(cols: u16) -> Self {
Self {
cells: vec![crate::Cell::new(); usize::from(cols)],
wrapped: false,
}
}
fn cols(&self) -> u16 {
self.cells
.len()
.try_into()
// we limit the number of cols to a u16 (see Size)
.unwrap()
}
pub fn clear(&mut self, attrs: crate::attrs::Attrs) {
for cell in &mut self.cells {
cell.clear(attrs);
}
self.wrapped = false;
}
/// True when every cell on this row holds no glyph. Used by the
/// alt-screen-to-scrollback copy path (issue #88) to skip the
/// trailing blank rows a TUI did not draw into.
pub fn is_blank(&self) -> bool {
!self.cells.iter().any(|c| c.has_contents())
}
fn cells(&self) -> impl Iterator- {
self.cells.iter()
}
pub fn get(&self, col: u16) -> Option<&crate::Cell> {
self.cells.get(usize::from(col))
}
pub fn get_mut(&mut self, col: u16) -> Option<&mut crate::Cell> {
self.cells.get_mut(usize::from(col))
}
pub fn insert(&mut self, i: u16, cell: crate::Cell) {
self.cells.insert(usize::from(i), cell);
self.wrapped = false;
}
pub fn remove(&mut self, i: u16) {
self.clear_wide(i);
self.cells.remove(usize::from(i));
self.wrapped = false;
}
pub fn erase(&mut self, i: u16, attrs: crate::attrs::Attrs) {
let wide = self.cells[usize::from(i)].is_wide();
self.clear_wide(i);
self.cells[usize::from(i)].clear(attrs);
if i == self.cols() - if wide { 2 } else { 1 } {
self.wrapped = false;
}
}
pub fn truncate(&mut self, len: u16) {
self.cells.truncate(usize::from(len));
self.wrapped = false;
let last_cell = &mut self.cells[usize::from(len) - 1];
if last_cell.is_wide() {
last_cell.clear(*last_cell.attrs());
}
}
pub fn resize(&mut self, len: u16, cell: crate::Cell) {
self.cells.resize(usize::from(len), cell);
self.wrapped = false;
}
pub fn wrap(&mut self, wrap: bool) {
self.wrapped = wrap;
}
pub fn wrapped(&self) -> bool {
self.wrapped
}
pub fn clear_wide(&mut self, col: u16) {
let col_idx = usize::from(col);
if col_idx >= self.cells.len() {
return;
}
let cell = &self.cells[col_idx];
if cell.is_wide() {
let next = usize::from(col + 1);
if next < self.cells.len() {
let attrs = *self.cells[next].attrs();
self.cells[next].clear(attrs);
}
} else if cell.is_wide_continuation() {
if col > 0 {
let prev = usize::from(col - 1);
let attrs = *self.cells[prev].attrs();
self.cells[prev].clear(attrs);
}
}
}
pub fn write_contents(
&self,
contents: &mut String,
start: u16,
width: u16,
wrapping: bool,
) {
let mut prev_was_wide = false;
let mut prev_col = start;
for (col, cell) in self
.cells()
.enumerate()
.skip(usize::from(start))
.take(usize::from(width))
{
if prev_was_wide {
prev_was_wide = false;
continue;
}
prev_was_wide = cell.is_wide();
// we limit the number of cols to a u16 (see Size)
let col: u16 = col.try_into().unwrap();
if cell.has_contents() {
for _ in 0..(col - prev_col) {
contents.push(' ');
}
prev_col += col - prev_col;
contents.push_str(cell.contents());
prev_col += if cell.is_wide() { 2 } else { 1 };
}
}
if prev_col == start && wrapping {
contents.push('\n');
}
}
pub fn write_contents_formatted(
&self,
contents: &mut Vec
,
start: u16,
width: u16,
row: u16,
wrapping: bool,
prev_pos: Option,
prev_attrs: Option,
) -> (crate::grid::Pos, crate::attrs::Attrs) {
let mut prev_was_wide = false;
let default_cell = crate::Cell::new();
let mut prev_pos = prev_pos.unwrap_or_else(|| {
if wrapping {
crate::grid::Pos {
row: row - 1,
col: self.cols(),
}
} else {
crate::grid::Pos { row, col: start }
}
});
let mut prev_attrs = prev_attrs.unwrap_or_default();
let first_cell = &self.cells[usize::from(start)];
if wrapping && first_cell == &default_cell {
let default_attrs = default_cell.attrs();
if &prev_attrs != default_attrs {
default_attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *default_attrs;
}
contents.push(b' ');
crate::term::Backspace.write_buf(contents);
crate::term::EraseChar::new(1).write_buf(contents);
prev_pos = crate::grid::Pos { row, col: 0 };
}
let mut erase: Option<(u16, &crate::attrs::Attrs)> = None;
for (col, cell) in self
.cells()
.enumerate()
.skip(usize::from(start))
.take(usize::from(width))
{
if prev_was_wide {
prev_was_wide = false;
continue;
}
prev_was_wide = cell.is_wide();
// we limit the number of cols to a u16 (see Size)
let col: u16 = col.try_into().unwrap();
let pos = crate::grid::Pos { row, col };
if let Some((prev_col, attrs)) = erase {
if cell.has_contents() || cell.attrs() != attrs {
let new_pos = crate::grid::Pos { row, col: prev_col };
if wrapping
&& prev_pos.row + 1 == new_pos.row
&& prev_pos.col >= self.cols()
{
if new_pos.col > 0 {
contents.extend(
" ".repeat(usize::from(new_pos.col))
.as_bytes(),
);
} else {
contents.extend(b" ");
crate::term::Backspace.write_buf(contents);
}
} else {
crate::term::MoveFromTo::new(prev_pos, new_pos)
.write_buf(contents);
}
prev_pos = new_pos;
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
crate::term::EraseChar::new(pos.col - prev_col)
.write_buf(contents);
erase = None;
}
}
if cell != &default_cell {
let attrs = cell.attrs();
if cell.has_contents() {
if pos != prev_pos {
if !wrapping
|| prev_pos.row + 1 != pos.row
|| prev_pos.col
< self.cols() - u16::from(cell.is_wide())
|| pos.col != 0
{
crate::term::MoveFromTo::new(prev_pos, pos)
.write_buf(contents);
}
prev_pos = pos;
}
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
prev_pos.col += if cell.is_wide() { 2 } else { 1 };
let cell_contents = cell.contents();
contents.extend(cell_contents.as_bytes());
} else if erase.is_none() {
erase = Some((pos.col, attrs));
}
}
}
if let Some((prev_col, attrs)) = erase {
let new_pos = crate::grid::Pos { row, col: prev_col };
if wrapping
&& prev_pos.row + 1 == new_pos.row
&& prev_pos.col >= self.cols()
{
if new_pos.col > 0 {
contents.extend(
" ".repeat(usize::from(new_pos.col)).as_bytes(),
);
} else {
contents.extend(b" ");
crate::term::Backspace.write_buf(contents);
}
} else {
crate::term::MoveFromTo::new(prev_pos, new_pos)
.write_buf(contents);
}
prev_pos = new_pos;
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
crate::term::ClearRowForward.write_buf(contents);
}
(prev_pos, prev_attrs)
}
// while it's true that most of the logic in this is identical to
// write_contents_formatted, i can't figure out how to break out the
// common parts without making things noticeably slower.
pub fn write_contents_diff(
&self,
contents: &mut Vec,
prev: &Self,
start: u16,
width: u16,
row: u16,
wrapping: bool,
prev_wrapping: bool,
mut prev_pos: crate::grid::Pos,
mut prev_attrs: crate::attrs::Attrs,
) -> (crate::grid::Pos, crate::attrs::Attrs) {
let mut prev_was_wide = false;
let first_cell = &self.cells[usize::from(start)];
let prev_first_cell = &prev.cells[usize::from(start)];
if wrapping
&& !prev_wrapping
&& first_cell == prev_first_cell
&& prev_pos.row + 1 == row
&& prev_pos.col
>= self.cols() - u16::from(prev_first_cell.is_wide())
{
let first_cell_attrs = first_cell.attrs();
if &prev_attrs != first_cell_attrs {
first_cell_attrs
.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *first_cell_attrs;
}
let mut cell_contents = prev_first_cell.contents();
let need_erase = if cell_contents.is_empty() {
cell_contents = " ";
true
} else {
false
};
contents.extend(cell_contents.as_bytes());
crate::term::Backspace.write_buf(contents);
if prev_first_cell.is_wide() {
crate::term::Backspace.write_buf(contents);
}
if need_erase {
crate::term::EraseChar::new(1).write_buf(contents);
}
prev_pos = crate::grid::Pos { row, col: 0 };
}
let mut erase: Option<(u16, &crate::attrs::Attrs)> = None;
for (col, (cell, prev_cell)) in self
.cells()
.zip(prev.cells())
.enumerate()
.skip(usize::from(start))
.take(usize::from(width))
{
if prev_was_wide {
prev_was_wide = false;
continue;
}
prev_was_wide = cell.is_wide();
// we limit the number of cols to a u16 (see Size)
let col: u16 = col.try_into().unwrap();
let pos = crate::grid::Pos { row, col };
if let Some((prev_col, attrs)) = erase {
if cell.has_contents() || cell.attrs() != attrs {
let new_pos = crate::grid::Pos { row, col: prev_col };
if wrapping
&& prev_pos.row + 1 == new_pos.row
&& prev_pos.col >= self.cols()
{
if new_pos.col > 0 {
contents.extend(
" ".repeat(usize::from(new_pos.col))
.as_bytes(),
);
} else {
contents.extend(b" ");
crate::term::Backspace.write_buf(contents);
}
} else {
crate::term::MoveFromTo::new(prev_pos, new_pos)
.write_buf(contents);
}
prev_pos = new_pos;
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
crate::term::EraseChar::new(pos.col - prev_col)
.write_buf(contents);
erase = None;
}
}
if cell != prev_cell {
let attrs = cell.attrs();
if cell.has_contents() {
if pos != prev_pos {
if !wrapping
|| prev_pos.row + 1 != pos.row
|| prev_pos.col
< self.cols() - u16::from(cell.is_wide())
|| pos.col != 0
{
crate::term::MoveFromTo::new(prev_pos, pos)
.write_buf(contents);
}
prev_pos = pos;
}
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
prev_pos.col += if cell.is_wide() { 2 } else { 1 };
contents.extend(cell.contents().as_bytes());
} else if erase.is_none() {
erase = Some((pos.col, attrs));
}
}
}
if let Some((prev_col, attrs)) = erase {
let new_pos = crate::grid::Pos { row, col: prev_col };
if wrapping
&& prev_pos.row + 1 == new_pos.row
&& prev_pos.col >= self.cols()
{
if new_pos.col > 0 {
contents.extend(
" ".repeat(usize::from(new_pos.col)).as_bytes(),
);
} else {
contents.extend(b" ");
crate::term::Backspace.write_buf(contents);
}
} else {
crate::term::MoveFromTo::new(prev_pos, new_pos)
.write_buf(contents);
}
prev_pos = new_pos;
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
crate::term::ClearRowForward.write_buf(contents);
}
// if this row is going from wrapped to not wrapped, we need to erase
// and redraw the last character to break wrapping. if this row is
// wrapped, we need to redraw the last character without erasing it to
// position the cursor after the end of the line correctly so that
// drawing the next line can just start writing and be wrapped.
if (!self.wrapped && prev.wrapped) || (!prev.wrapped && self.wrapped)
{
let end_pos = if self.cells[usize::from(self.cols() - 1)]
.is_wide_continuation()
{
crate::grid::Pos {
row,
col: self.cols() - 2,
}
} else {
crate::grid::Pos {
row,
col: self.cols() - 1,
}
};
crate::term::MoveFromTo::new(prev_pos, end_pos)
.write_buf(contents);
prev_pos = end_pos;
if !self.wrapped {
crate::term::EraseChar::new(1).write_buf(contents);
}
let end_cell = &self.cells[usize::from(end_pos.col)];
if end_cell.has_contents() {
let attrs = end_cell.attrs();
if &prev_attrs != attrs {
attrs.write_escape_code_diff(contents, &prev_attrs);
prev_attrs = *attrs;
}
contents.extend(end_cell.contents().as_bytes());
prev_pos.col += if end_cell.is_wide() { 2 } else { 1 };
}
}
(prev_pos, prev_attrs)
}
}
================================================
FILE: crates/vt100-psmux/src/screen.rs
================================================
use crate::term::BufWrite as _;
use unicode_width::UnicodeWidthChar as _;
/// Parse an OSC 7 URI into a filesystem path.
/// Accepts `file://hostname/path`, `file:///path`, or a bare `/path`.
/// Percent-decodes the path component.
fn parse_osc7_uri(raw: &str) -> String {
let stripped = if let Some(rest) = raw.strip_prefix("file://") {
// Skip hostname: everything up to the next '/'
if let Some(slash) = rest.find('/') {
&rest[slash..]
} else {
rest
}
} else {
raw
};
percent_decode(stripped)
}
/// Minimal percent-decoding for OSC 7 paths (e.g. `%20` → ` `).
fn percent_decode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
hex_val(bytes[i + 1]),
hex_val(bytes[i + 2]),
) {
out.push(char::from(hi << 4 | lo));
i += 3;
continue;
}
}
out.push(char::from(bytes[i]));
i += 1;
}
out
}
fn hex_val(b: u8) -> Option {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
const MODE_APPLICATION_KEYPAD: u8 = 0b0000_0001;
const MODE_APPLICATION_CURSOR: u8 = 0b0000_0010;
const MODE_HIDE_CURSOR: u8 = 0b0000_0100;
const MODE_ALTERNATE_SCREEN: u8 = 0b0000_1000;
const MODE_BRACKETED_PASTE: u8 = 0b0001_0000;
/// The xterm mouse handling mode currently in use.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
pub enum MouseProtocolMode {
/// Mouse handling is disabled.
#[default]
None,
/// Mouse button events should be reported on button press. Also known as
/// X10 mouse mode.
Press,
/// Mouse button events should be reported on button press and release.
/// Also known as VT200 mouse mode.
PressRelease,
// Highlight,
/// Mouse button events should be reported on button press and release, as
/// well as when the mouse moves between cells while a button is held
/// down.
ButtonMotion,
/// Mouse button events should be reported on button press and release,
/// and mouse motion events should be reported when the mouse moves
/// between cells regardless of whether a button is held down or not.
AnyMotion,
// DecLocator,
}
/// The encoding to use for the enabled [`MouseProtocolMode`].
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
pub enum MouseProtocolEncoding {
/// Default single-printable-byte encoding.
#[default]
Default,
/// UTF-8-based encoding.
Utf8,
/// SGR-like encoding.
Sgr,
// Urxvt,
}
/// Represents the overall terminal state.
#[derive(Clone, Debug)]
pub struct Screen {
grid: crate::grid::Grid,
alternate_grid: crate::grid::Grid,
attrs: crate::attrs::Attrs,
saved_attrs: crate::attrs::Attrs,
modes: u8,
mouse_protocol_mode: MouseProtocolMode,
mouse_protocol_encoding: MouseProtocolEncoding,
/// Window title set by the application via OSC 0 or OSC 2.
osc_title: String,
/// Path announced by the shell via OSC 7 (`\e]7;file://host/path\a`).
/// Used as a fallback for CWD when PEB walking fails (SSH, WSL).
osc7_path: Option,
/// Progress indicator state set via OSC 9;4 (Windows Terminal progress).
/// Format: `Some((state, value))` where state ∈ {0=hide,1=default,2=error,
/// 3=indeterminate,4=warning} and value ∈ 0..=100. `None` until first set;
/// stays `Some` after that so a clear (state=0) is also forwarded.
osc94_progress: Option<(u8, u8)>,
/// Pending OSC 52 clipboard payload emitted by the child process inside
/// this pane. Format: `Some((selector_bytes, base64_payload))`. The
/// selector is the raw selector field from the OSC (e.g. `b"c"`,
/// `b"p"`, etc., or empty) and `base64_payload` is the still-encoded
/// data string. Consumed once via [`Screen::take_clipboard`]; the
/// psmux server drains this and stages it onto `App.clipboard_osc52`
/// so the client re-emits OSC 52 on its own stdout to reach the host
/// terminal (Windows Terminal, etc.).
osc52_clipboard: Option<(Vec, Vec)>,
/// Set to `true` when the screen is cleared (CSI 2J) while
/// `squelch_clear_pending` is active. The layout serialiser
/// checks this flag to know that `cls` has finished.
pub(crate) squelch_cleared: bool,
/// Set by the server before injecting `cd; cls`. When true,
/// the next CSI 2J (erase display mode 2) sets `squelch_cleared`.
pub(crate) squelch_clear_pending: bool,
/// Incremented each time the parser encounters a standalone BEL (0x07)
/// that is NOT an OSC/DCS/APC string terminator. Use `take_audible_bell()`
/// to consume the counter.
pub(crate) audible_bell_count: u32,
/// Controls how alternate-screen exits interact with main-grid
/// scrollback. Default `true` = legacy behaviour: alt-screen
/// content is ephemeral, vanishes on exit (matches xterm/tmux
/// default). When set `false`, the visible rows of the alt grid
/// are copied into main-grid scrollback at the moment of exit,
/// so a user running `capture-pane -S` or copy-mode page-up
/// after a TUI session sees what was on screen when the app left.
///
/// We keep the option name `alternate-screen` to match tmux, but
/// the Windows implementation differs: ConPTY emits its own
/// "clear + restore" sequences around 1049 toggles regardless of
/// whether we honour the toggle, so simply dropping 1049 (the
/// tmux approach) does not preserve content on this platform.
/// Copy-on-exit is the equivalent end-user behaviour.
pub(crate) allow_alternate_screen: bool,
}
impl Screen {
pub(crate) fn new(
size: crate::grid::Size,
scrollback_len: usize,
) -> Self {
let mut grid = crate::grid::Grid::new(size, scrollback_len);
grid.allocate_rows();
Self {
grid,
alternate_grid: crate::grid::Grid::new(size, 0),
attrs: crate::attrs::Attrs::default(),
saved_attrs: crate::attrs::Attrs::default(),
modes: 0,
mouse_protocol_mode: MouseProtocolMode::default(),
mouse_protocol_encoding: MouseProtocolEncoding::default(),
osc_title: String::new(),
osc7_path: None,
osc94_progress: None,
osc52_clipboard: None,
squelch_cleared: false,
squelch_clear_pending: false,
audible_bell_count: 0,
allow_alternate_screen: true,
}
}
/// Resizes the terminal.
pub fn set_size(&mut self, rows: u16, cols: u16) {
self.grid.set_size(crate::grid::Size { rows, cols });
self.alternate_grid
.set_size(crate::grid::Size { rows, cols });
}
/// Returns the current size of the terminal.
///
/// The return value will be (rows, cols).
#[must_use]
pub fn size(&self) -> (u16, u16) {
let size = self.grid().size();
(size.rows, size.cols)
}
/// Scrolls to the given position in the scrollback.
///
/// This position indicates the offset from the top of the screen, and
/// should be `0` to put the normal screen in view.
///
/// This affects the return values of methods called on the screen: for
/// instance, `screen.cell(0, 0)` will return the top left corner of the
/// screen after taking the scrollback offset into account.
///
/// The value given will be clamped to the actual size of the scrollback.
pub fn set_scrollback(&mut self, rows: usize) {
self.grid_mut().set_scrollback(rows);
}
/// Returns the number of rows currently held in main-grid
/// scrollback (the actual retained count, not the configured
/// maximum). Always reads the main grid, even while alt-screen
/// is active — `#{history_size}` and capture-pane-S are about
/// "what can the user scroll back to", and that lives on the
/// main grid regardless of which grid is currently rendering.
#[must_use]
pub fn scrollback_filled(&self) -> usize {
self.grid.scrollback_filled()
}
/// Updates the maximum scrollback buffer size for the main grid. Rows
/// in excess of the new limit are trimmed from the oldest end. The
/// alternate grid is intentionally left at zero scrollback (apps like
/// vim use the alternate screen and do not retain history).
pub fn set_scrollback_len(&mut self, new_len: usize) {
self.grid_mut().set_scrollback_len(new_len);
}
/// Returns the configured maximum size of the scrollback buffer.
#[must_use]
pub fn scrollback_len(&self) -> usize {
self.grid().scrollback_len()
}
/// Whether DEC private modes 47/1049 (alternate screen) are honoured.
#[must_use]
pub fn allow_alternate_screen(&self) -> bool {
self.allow_alternate_screen
}
/// Toggle whether alt-screen content is preserved into main-grid
/// scrollback when the alt screen exits. See the field comment.
/// If we are currently inside alt mode at the moment of toggling
/// off, also flush what's visible into scrollback right now —
/// otherwise a user who flipped the option on while a TUI is
/// already running would lose the current frame.
pub fn set_allow_alternate_screen(&mut self, allowed: bool) {
let was = self.allow_alternate_screen;
self.allow_alternate_screen = allowed;
if !allowed && was && self.mode(MODE_ALTERNATE_SCREEN) {
self.copy_alt_visible_to_main_scrollback();
}
}
/// Returns the current position in the scrollback.
///
/// This position indicates the offset from the top of the screen, and is
/// `0` when the normal screen is in view.
#[must_use]
pub fn scrollback(&self) -> usize {
self.grid().scrollback()
}
/// Returns the text contents of the terminal.
///
/// This will not include any formatting information, and will be in plain
/// text format.
#[must_use]
pub fn contents(&self) -> String {
let mut contents = String::new();
self.write_contents(&mut contents);
contents
}
fn write_contents(&self, contents: &mut String) {
self.grid().write_contents(contents);
}
/// Returns the text contents of the terminal by row, restricted to the
/// given subset of columns.
///
/// This will not include any formatting information, and will be in plain
/// text format.
///
/// Newlines will not be included.
pub fn rows(
&self,
start: u16,
width: u16,
) -> impl Iterator- + '_ {
self.grid().visible_rows().map(move |row| {
let mut contents = String::new();
row.write_contents(&mut contents, start, width, false);
contents
})
}
/// Returns the text contents of the terminal logically between two cells.
/// This will include the remainder of the starting row after `start_col`,
/// followed by the entire contents of the rows between `start_row` and
/// `end_row`, followed by the beginning of the `end_row` up until
/// `end_col`. This is useful for things like determining the contents of
/// a clipboard selection.
#[must_use]
pub fn contents_between(
&self,
start_row: u16,
start_col: u16,
end_row: u16,
end_col: u16,
) -> String {
match start_row.cmp(&end_row) {
std::cmp::Ordering::Less => {
let (_, cols) = self.size();
let mut contents = String::new();
for (i, row) in self
.grid()
.visible_rows()
.enumerate()
.skip(usize::from(start_row))
.take(usize::from(end_row) - usize::from(start_row) + 1)
{
if i == usize::from(start_row) {
row.write_contents(
&mut contents,
start_col,
cols - start_col,
false,
);
if !row.wrapped() {
contents.push('\n');
}
} else if i == usize::from(end_row) {
row.write_contents(&mut contents, 0, end_col, false);
} else {
row.write_contents(&mut contents, 0, cols, false);
if !row.wrapped() {
contents.push('\n');
}
}
}
contents
}
std::cmp::Ordering::Equal => {
if start_col < end_col {
self.rows(start_col, end_col - start_col)
.nth(usize::from(start_row))
.unwrap_or_default()
} else {
String::new()
}
}
std::cmp::Ordering::Greater => String::new(),
}
}
/// Return escape codes sufficient to reproduce the entire contents of the
/// current terminal state. This is a convenience wrapper around
/// [`contents_formatted`](Self::contents_formatted) and
/// [`input_mode_formatted`](Self::input_mode_formatted).
#[must_use]
pub fn state_formatted(&self) -> Vec
{
let mut contents = vec![];
self.write_contents_formatted(&mut contents);
self.write_input_mode_formatted(&mut contents);
contents
}
/// Return escape codes sufficient to turn the terminal state of the
/// screen `prev` into the current terminal state. This is a convenience
/// wrapper around [`contents_diff`](Self::contents_diff) and
/// [`input_mode_diff`](Self::input_mode_diff).
#[must_use]
pub fn state_diff(&self, prev: &Self) -> Vec {
let mut contents = vec![];
self.write_contents_diff(&mut contents, prev);
self.write_input_mode_diff(&mut contents, prev);
contents
}
/// Returns the formatted visible contents of the terminal.
///
/// Formatting information will be included inline as terminal escape
/// codes. The result will be suitable for feeding directly to a raw
/// terminal parser, and will result in the same visual output.
#[must_use]
pub fn contents_formatted(&self) -> Vec {
let mut contents = vec![];
self.write_contents_formatted(&mut contents);
contents
}
fn write_contents_formatted(&self, contents: &mut Vec) {
crate::term::HideCursor::new(self.hide_cursor()).write_buf(contents);
let prev_attrs = self.grid().write_contents_formatted(contents);
self.attrs.write_escape_code_diff(contents, &prev_attrs);
}
/// Returns the formatted visible contents of the terminal by row,
/// restricted to the given subset of columns.
///
/// Formatting information will be included inline as terminal escape
/// codes. The result will be suitable for feeding directly to a raw
/// terminal parser, and will result in the same visual output.
///
/// You are responsible for positioning the cursor before printing each
/// row, and the final cursor position after displaying each row is
/// unspecified.
// the unwraps in this method shouldn't be reachable
#[allow(clippy::missing_panics_doc)]
pub fn rows_formatted(
&self,
start: u16,
width: u16,
) -> impl Iterator- > + '_ {
let mut wrapping = false;
self.grid().visible_rows().enumerate().map(move |(i, row)| {
// number of rows in a grid is stored in a u16 (see Size), so
// visible_rows can never return enough rows to overflow here
let i = i.try_into().unwrap();
let mut contents = vec![];
row.write_contents_formatted(
&mut contents,
start,
width,
i,
wrapping,
None,
None,
);
if start == 0 && width == self.grid.size().cols {
wrapping = row.wrapped();
}
contents
})
}
/// Returns a terminal byte stream sufficient to turn the visible contents
/// of the screen described by `prev` into the visible contents of the
/// screen described by `self`.
///
/// The result of rendering `prev.contents_formatted()` followed by
/// `self.contents_diff(prev)` should be equivalent to the result of
/// rendering `self.contents_formatted()`. This is primarily useful when
/// you already have a terminal parser whose state is described by `prev`,
/// since the diff will likely require less memory and cause less
/// flickering than redrawing the entire screen contents.
#[must_use]
pub fn contents_diff(&self, prev: &Self) -> Vec
{
let mut contents = vec![];
self.write_contents_diff(&mut contents, prev);
contents
}
fn write_contents_diff(&self, contents: &mut Vec, prev: &Self) {
if self.hide_cursor() != prev.hide_cursor() {
crate::term::HideCursor::new(self.hide_cursor())
.write_buf(contents);
}
let prev_attrs = self.grid().write_contents_diff(
contents,
prev.grid(),
prev.attrs,
);
self.attrs.write_escape_code_diff(contents, &prev_attrs);
}
/// Returns a sequence of terminal byte streams sufficient to turn the
/// visible contents of the subset of each row from `prev` (as described
/// by `start` and `width`) into the visible contents of the corresponding
/// row subset in `self`.
///
/// You are responsible for positioning the cursor before printing each
/// row, and the final cursor position after displaying each row is
/// unspecified.
// the unwraps in this method shouldn't be reachable
#[allow(clippy::missing_panics_doc)]
pub fn rows_diff<'a>(
&'a self,
prev: &'a Self,
start: u16,
width: u16,
) -> impl Iterator- > + 'a {
self.grid()
.visible_rows()
.zip(prev.grid().visible_rows())
.enumerate()
.map(move |(i, (row, prev_row))| {
// number of rows in a grid is stored in a u16 (see Size), so
// visible_rows can never return enough rows to overflow here
let i = i.try_into().unwrap();
let mut contents = vec![];
row.write_contents_diff(
&mut contents,
prev_row,
start,
width,
i,
false,
false,
crate::grid::Pos { row: i, col: start },
crate::attrs::Attrs::default(),
);
contents
})
}
/// Returns terminal escape sequences sufficient to set the current
/// terminal's input modes.
///
/// Supported modes are:
/// * application keypad
/// * application cursor
/// * bracketed paste
/// * xterm mouse support
#[must_use]
pub fn input_mode_formatted(&self) -> Vec
{
let mut contents = vec![];
self.write_input_mode_formatted(&mut contents);
contents
}
fn write_input_mode_formatted(&self, contents: &mut Vec) {
crate::term::ApplicationKeypad::new(
self.mode(MODE_APPLICATION_KEYPAD),
)
.write_buf(contents);
crate::term::ApplicationCursor::new(
self.mode(MODE_APPLICATION_CURSOR),
)
.write_buf(contents);
crate::term::BracketedPaste::new(self.mode(MODE_BRACKETED_PASTE))
.write_buf(contents);
crate::term::MouseProtocolMode::new(
self.mouse_protocol_mode,
MouseProtocolMode::None,
)
.write_buf(contents);
crate::term::MouseProtocolEncoding::new(
self.mouse_protocol_encoding,
MouseProtocolEncoding::Default,
)
.write_buf(contents);
}
/// Returns terminal escape sequences sufficient to change the previous
/// terminal's input modes to the input modes enabled in the current
/// terminal.
#[must_use]
pub fn input_mode_diff(&self, prev: &Self) -> Vec {
let mut contents = vec![];
self.write_input_mode_diff(&mut contents, prev);
contents
}
fn write_input_mode_diff(&self, contents: &mut Vec, prev: &Self) {
if self.mode(MODE_APPLICATION_KEYPAD)
!= prev.mode(MODE_APPLICATION_KEYPAD)
{
crate::term::ApplicationKeypad::new(
self.mode(MODE_APPLICATION_KEYPAD),
)
.write_buf(contents);
}
if self.mode(MODE_APPLICATION_CURSOR)
!= prev.mode(MODE_APPLICATION_CURSOR)
{
crate::term::ApplicationCursor::new(
self.mode(MODE_APPLICATION_CURSOR),
)
.write_buf(contents);
}
if self.mode(MODE_BRACKETED_PASTE) != prev.mode(MODE_BRACKETED_PASTE)
{
crate::term::BracketedPaste::new(self.mode(MODE_BRACKETED_PASTE))
.write_buf(contents);
}
crate::term::MouseProtocolMode::new(
self.mouse_protocol_mode,
prev.mouse_protocol_mode,
)
.write_buf(contents);
crate::term::MouseProtocolEncoding::new(
self.mouse_protocol_encoding,
prev.mouse_protocol_encoding,
)
.write_buf(contents);
}
/// Returns terminal escape sequences sufficient to set the current
/// terminal's drawing attributes.
///
/// Supported drawing attributes are:
/// * fgcolor
/// * bgcolor
/// * bold
/// * dim
/// * italic
/// * underline
/// * inverse
///
/// This is not typically necessary, since
/// [`contents_formatted`](Self::contents_formatted) will leave
/// the current active drawing attributes in the correct state, but this
/// can be useful in the case of drawing additional things on top of a
/// terminal output, since you will need to restore the terminal state
/// without the terminal contents necessarily being the same.
#[must_use]
pub fn attributes_formatted(&self) -> Vec {
let mut contents = vec![];
self.write_attributes_formatted(&mut contents);
contents
}
fn write_attributes_formatted(&self, contents: &mut Vec) {
crate::term::ClearAttrs.write_buf(contents);
self.attrs.write_escape_code_diff(
contents,
&crate::attrs::Attrs::default(),
);
}
/// Returns the current cursor position of the terminal.
///
/// The return value will be (row, col).
#[must_use]
pub fn cursor_position(&self) -> (u16, u16) {
let pos = self.grid().pos();
(pos.row, pos.col)
}
/// Returns terminal escape sequences sufficient to set the current
/// cursor state of the terminal.
///
/// This is not typically necessary, since
/// [`contents_formatted`](Self::contents_formatted) will leave
/// the cursor in the correct state, but this can be useful in the case of
/// drawing additional things on top of a terminal output, since you will
/// need to restore the terminal state without the terminal contents
/// necessarily being the same.
///
/// Note that the bytes returned by this function may alter the active
/// drawing attributes, because it may require redrawing existing cells in
/// order to position the cursor correctly (for instance, in the case
/// where the cursor is past the end of a row). Therefore, you should
/// ensure to reset the active drawing attributes if necessary after
/// processing this data, for instance by using
/// [`attributes_formatted`](Self::attributes_formatted).
#[must_use]
pub fn cursor_state_formatted(&self) -> Vec {
let mut contents = vec![];
self.write_cursor_state_formatted(&mut contents);
contents
}
fn write_cursor_state_formatted(&self, contents: &mut Vec) {
crate::term::HideCursor::new(self.hide_cursor()).write_buf(contents);
self.grid()
.write_cursor_position_formatted(contents, None, None);
// we don't just call write_attributes_formatted here, because that
// would still be confusing - consider the case where the user sets
// their own unrelated drawing attributes (on a different parser
// instance) and then calls cursor_state_formatted. just documenting
// it and letting the user handle it on their own is more
// straightforward.
}
/// Returns the [`Cell`](crate::Cell) object at the given location in the
/// terminal, if it exists.
#[must_use]
pub fn cell(&self, row: u16, col: u16) -> Option<&crate::Cell> {
self.grid().visible_cell(crate::grid::Pos { row, col })
}
/// Returns whether the text in row `row` should wrap to the next line.
#[must_use]
pub fn row_wrapped(&self, row: u16) -> bool {
self.grid()
.visible_row(row)
.is_some_and(crate::row::Row::wrapped)
}
/// Returns whether the alternate screen is currently in use.
#[must_use]
pub fn alternate_screen(&self) -> bool {
self.mode(MODE_ALTERNATE_SCREEN)
}
/// Returns whether the terminal should be in application keypad mode.
#[must_use]
pub fn application_keypad(&self) -> bool {
self.mode(MODE_APPLICATION_KEYPAD)
}
/// Returns whether the terminal should be in application cursor mode.
#[must_use]
pub fn application_cursor(&self) -> bool {
self.mode(MODE_APPLICATION_CURSOR)
}
/// Returns whether the terminal should be in hide cursor mode.
#[must_use]
pub fn hide_cursor(&self) -> bool {
self.mode(MODE_HIDE_CURSOR)
}
/// Returns whether the terminal should be in bracketed paste mode.
#[must_use]
pub fn bracketed_paste(&self) -> bool {
self.mode(MODE_BRACKETED_PASTE)
}
/// Returns the currently active [`MouseProtocolMode`].
#[must_use]
pub fn mouse_protocol_mode(&self) -> MouseProtocolMode {
self.mouse_protocol_mode
}
/// Returns the currently active [`MouseProtocolEncoding`].
#[must_use]
pub fn mouse_protocol_encoding(&self) -> MouseProtocolEncoding {
self.mouse_protocol_encoding
}
/// Returns the window title set via OSC 0 or OSC 2.
#[must_use]
pub fn title(&self) -> &str {
&self.osc_title
}
/// Store a window title set via OSC 0 or OSC 2.
pub fn set_title(&mut self, raw: &[u8]) {
if let Ok(s) = std::str::from_utf8(raw) {
self.osc_title = s.to_string();
}
}
/// Returns the path announced by the shell via OSC 7, if any.
#[must_use]
pub fn path(&self) -> Option<&str> {
self.osc7_path.as_deref()
}
/// Store a path announced via OSC 7.
/// The raw URI is parsed: `file://host/path` → `/path`.
pub fn set_path(&mut self, raw: &[u8]) {
if let Ok(s) = std::str::from_utf8(raw) {
let path = parse_osc7_uri(s);
if !path.is_empty() {
self.osc7_path = Some(path);
}
}
}
/// Returns the most recent OSC 9;4 progress indicator state, if any.
/// `Some((state, value))` once an OSC 9;4 has been received, even when
/// state==0 (hide); `None` when none has ever been received. Consumers
/// (psmux server) forward this to the host terminal so tools like
/// GitHub Copilot CLI keep working inside a pane.
#[must_use]
pub fn progress(&self) -> Option<(u8, u8)> {
self.osc94_progress
}
/// Store an OSC 9;4 progress indicator. State is clamped to 0..=4 and
/// value to 0..=100 to match the Windows Terminal contract.
pub fn set_progress(&mut self, state: u8, value: u8) {
let s = state.min(4);
let v = value.min(100);
self.osc94_progress = Some((s, v));
}
/// Store an OSC 52 clipboard copy request emitted by the child.
/// `selector` is the raw selector field (e.g. `b"c"`), `data` is the
/// base64-encoded payload exactly as received. Later writes overwrite
/// earlier ones until [`Screen::take_clipboard`] consumes the slot.
pub fn set_clipboard(&mut self, selector: &[u8], data: &[u8]) {
self.osc52_clipboard = Some((selector.to_vec(), data.to_vec()));
}
/// Returns and clears any pending OSC 52 clipboard payload. Consume-once:
/// after a successful drain this returns `None` until the next OSC 52
/// arrives. Used by the psmux server to forward child-emitted clipboard
/// requests onto `App.clipboard_osc52`, which the client re-emits as an
/// OSC 52 sequence on its own stdout so the host terminal (Windows
/// Terminal, etc.) can perform the actual copy.
pub fn take_clipboard(&mut self) -> Option<(Vec, Vec)> {
self.osc52_clipboard.take()
}
/// Peek at the pending OSC 52 clipboard payload without consuming it.
/// Returns `None` if no copy request is currently staged.
#[must_use]
pub fn clipboard(&self) -> Option<(&[u8], &[u8])> {
self.osc52_clipboard
.as_ref()
.map(|(s, d)| (s.as_slice(), d.as_slice()))
}
/// Returns `true` if a screen clear (CSI 2J) was detected while
/// squelch was pending, signalling that `cls`/`clear` finished.
/// Calling this does NOT clear the flag; use [`take_squelch_cleared`]
/// for a consume-style check.
#[must_use]
pub fn squelch_cleared(&self) -> bool {
self.squelch_cleared
}
/// Returns `true` and resets the flag if screen clear was detected.
pub fn take_squelch_cleared(&mut self) -> bool {
let v = self.squelch_cleared;
self.squelch_cleared = false;
v
}
/// Returns `true` if one or more audible bells (standalone BEL, not OSC
/// terminators) were received since the last call. Resets the counter.
pub fn take_audible_bell(&mut self) -> bool {
let v = self.audible_bell_count;
self.audible_bell_count = 0;
v > 0
}
/// Arm the squelch detector: the next CSI 2J or CSI 3J will
/// set `squelch_cleared` to `true`.
pub fn set_squelch_clear_pending(&mut self, v: bool) {
self.squelch_clear_pending = v;
}
/// Internal: fire the squelch signal if armed.
fn check_squelch_signal(&mut self) {
if self.squelch_clear_pending {
self.squelch_cleared = true;
self.squelch_clear_pending = false;
}
}
/// Returns the currently active foreground color.
#[must_use]
pub fn fgcolor(&self) -> crate::Color {
self.attrs.fgcolor
}
/// Returns the currently active background color.
#[must_use]
pub fn bgcolor(&self) -> crate::Color {
self.attrs.bgcolor
}
/// Returns whether newly drawn text should be rendered with the bold text
/// attribute.
#[must_use]
pub fn bold(&self) -> bool {
self.attrs.bold()
}
/// Returns whether newly drawn text should be rendered with the dim text
/// attribute.
#[must_use]
pub fn dim(&self) -> bool {
self.attrs.dim()
}
/// Returns whether newly drawn text should be rendered with the italic
/// text attribute.
#[must_use]
pub fn italic(&self) -> bool {
self.attrs.italic()
}
/// Returns whether newly drawn text should be rendered with the
/// underlined text attribute.
#[must_use]
pub fn underline(&self) -> bool {
self.attrs.underline()
}
/// Returns whether newly drawn text should be rendered with the inverse
/// text attribute.
#[must_use]
pub fn inverse(&self) -> bool {
self.attrs.inverse()
}
pub(crate) fn grid(&self) -> &crate::grid::Grid {
if self.mode(MODE_ALTERNATE_SCREEN) {
&self.alternate_grid
} else {
&self.grid
}
}
fn grid_mut(&mut self) -> &mut crate::grid::Grid {
if self.mode(MODE_ALTERNATE_SCREEN) {
&mut self.alternate_grid
} else {
&mut self.grid
}
}
fn enter_alternate_grid(&mut self) {
self.grid_mut().set_scrollback(0);
self.set_mode(MODE_ALTERNATE_SCREEN);
self.alternate_grid.allocate_rows();
}
fn exit_alternate_grid(&mut self) {
// Issue #88: when the user has opted in via `alternate-screen
// off`, append the alt grid's currently-visible rows to the
// main grid's scrollback BEFORE flipping the mode. Done in
// this order so the rows are read off the alt grid (which is
// still selected by `grid()` while MODE_ALTERNATE_SCREEN is
// set) and pushed into the main grid's buffer. A no-op when
// the option is left at the default `on`.
if !self.allow_alternate_screen {
self.copy_alt_visible_to_main_scrollback();
}
self.clear_mode(MODE_ALTERNATE_SCREEN);
}
/// Append every non-blank visible row of the alt grid to the
/// main grid's scrollback. Trailing blank rows are skipped so a
/// TUI that didn't fill the screen does not leave a tail of empty
/// lines in scrollback. Cheap: O(rows × cols) per exit, with the
/// usual scrollback eviction rules applied by main grid's append.
fn copy_alt_visible_to_main_scrollback(&mut self) {
// Snapshot the alt grid's visible rows; we will hand them to
// the main grid afterwards.
let alt_rows: Vec = self
.alternate_grid
.drawing_rows()
.cloned()
.collect();
// Trim trailing blank rows — they're just empty lines beneath
// the TUI's last drawn row and would clutter scrollback.
let last_nonblank = alt_rows
.iter()
.rposition(|r| !r.is_blank())
.map(|i| i + 1)
.unwrap_or(0);
for row in alt_rows.into_iter().take(last_nonblank) {
self.grid.push_row_to_scrollback(row);
}
}
fn save_cursor(&mut self) {
self.grid_mut().save_cursor();
self.saved_attrs = self.attrs;
}
fn restore_cursor(&mut self) {
self.grid_mut().restore_cursor();
self.attrs = self.saved_attrs;
}
fn set_mode(&mut self, mode: u8) {
self.modes |= mode;
}
fn clear_mode(&mut self, mode: u8) {
self.modes &= !mode;
}
fn mode(&self, mode: u8) -> bool {
self.modes & mode != 0
}
fn set_mouse_mode(&mut self, mode: MouseProtocolMode) {
self.mouse_protocol_mode = mode;
}
fn clear_mouse_mode(&mut self, mode: MouseProtocolMode) {
if self.mouse_protocol_mode == mode {
self.mouse_protocol_mode = MouseProtocolMode::default();
}
}
fn set_mouse_encoding(&mut self, encoding: MouseProtocolEncoding) {
self.mouse_protocol_encoding = encoding;
}
fn clear_mouse_encoding(&mut self, encoding: MouseProtocolEncoding) {
if self.mouse_protocol_encoding == encoding {
self.mouse_protocol_encoding = MouseProtocolEncoding::default();
}
}
}
impl Screen {
pub(crate) fn text(&mut self, c: char) {
let pos = self.grid().pos();
let size = self.grid().size();
let attrs = self.attrs;
let width = c.width();
if width.is_none() && (u32::from(c)) < 256 {
// don't even try to draw control characters
return;
}
let width = width
.unwrap_or(1)
.try_into()
// width() can only return 0, 1, or 2
.unwrap();
// it doesn't make any sense to wrap if the last column in a row
// didn't already have contents. don't try to handle the case where a
// character wraps because there was only one column left in the
// previous row - literally everything handles this case differently,
// and this is tmux behavior (and also the simplest). i'm open to
// reconsidering this behavior, but only with a really good reason
// (xterm handles this by introducing the concept of triple width
// cells, which i really don't want to do).
let mut wrap = false;
if pos.col > size.cols - width {
let last_cell = self
.grid()
.drawing_cell(crate::grid::Pos {
row: pos.row,
col: size.cols - 1,
})
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always have a valid
// row value. size.cols - 1 is also always a valid column.
.unwrap();
if last_cell.has_contents() || last_cell.is_wide_continuation() {
wrap = true;
}
}
self.grid_mut().col_wrap(width, wrap);
let pos = self.grid().pos();
if width == 0 {
if pos.col > 0 {
let mut prev_cell = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row,
col: pos.col - 1,
})
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always have a
// valid row value. pos.col - 1 is valid because we just
// checked for pos.col > 0.
.unwrap();
if prev_cell.is_wide_continuation() {
prev_cell = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row,
col: pos.col - 2,
})
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always have a
// valid row value. we know pos.col - 2 is valid
// because the cell at pos.col - 1 is a wide
// continuation character, which means there must be
// the first half of the wide character before it.
.unwrap();
}
prev_cell.append(c);
} else if pos.row > 0 {
let prev_row = self
.grid()
.drawing_row(pos.row - 1)
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always have a
// valid row value. pos.row - 1 is valid because we just
// checked for pos.row > 0.
.unwrap();
if prev_row.wrapped() {
let mut prev_cell = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row - 1,
col: size.cols - 1,
})
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always have a
// valid row value. pos.row - 1 is valid because we
// just checked for pos.row > 0. col of size.cols - 1
// is always valid.
.unwrap();
if prev_cell.is_wide_continuation() {
prev_cell = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row - 1,
col: size.cols - 2,
})
// pos.row is valid, since it comes directly from
// self.grid().pos() which we assume to always
// have a valid row value. pos.row - 1 is valid
// because we just checked for pos.row > 0. col of
// size.cols - 2 is valid because the cell at
// size.cols - 1 is a wide continuation character,
// so it must have the first half of the wide
// character before it.
.unwrap();
}
prev_cell.append(c);
}
}
} else {
// After a resize, cells may be in inconsistent states (e.g.
// a wide char at the last column without its continuation).
// Use safe accessors to avoid panics on out-of-bounds.
if let Some(cell_ref) = self.grid().drawing_cell(pos) {
if cell_ref.is_wide_continuation() {
if let Some(prev_cell) = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row,
col: pos.col - 1,
})
{
prev_cell.clear(attrs);
}
}
}
let is_wide_at_pos = self
.grid()
.drawing_cell(pos)
.map_or(false, |c| c.is_wide());
if is_wide_at_pos {
if let Some(next_cell) = self
.grid_mut()
.drawing_cell_mut(crate::grid::Pos {
row: pos.row,
col: pos.col + 1,
})
{
next_cell.set(' ', attrs);
}
}
if let Some(cell) = self
.grid_mut()
.drawing_cell_mut(pos)
{
cell.set(c, attrs);
} else {
return;
}
self.grid_mut().col_inc(1);
if width > 1 {
let pos = self.grid().pos();
let is_wide_here = self
.grid()
.drawing_cell(pos)
.map_or(false, |c| c.is_wide());
if is_wide_here {
let next_next_pos = crate::grid::Pos {
row: pos.row,
col: pos.col + 1,
};
if let Some(next_next_cell) = self
.grid_mut()
.drawing_cell_mut(next_next_pos)
{
next_next_cell.clear(attrs);
if next_next_pos.col == size.cols - 1 {
if let Some(row) = self.grid_mut()
.drawing_row_mut(pos.row)
{
row.wrap(false);
}
}
}
}
if let Some(next_cell) = self
.grid_mut()
.drawing_cell_mut(pos)
{
next_cell.clear(crate::attrs::Attrs::default());
next_cell.set_wide_continuation(true);
}
self.grid_mut().col_inc(1);
}
}
}
// control codes
pub(crate) fn bs(&mut self) {
self.grid_mut().col_dec(1);
}
pub(crate) fn tab(&mut self) {
self.grid_mut().col_tab();
}
pub(crate) fn lf(&mut self) {
self.grid_mut().row_inc_scroll(1);
}
pub(crate) fn vt(&mut self) {
self.lf();
}
pub(crate) fn ff(&mut self) {
self.lf();
}
pub(crate) fn cr(&mut self) {
self.grid_mut().col_set(0);
}
// escape codes
// ESC 7
pub(crate) fn decsc(&mut self) {
self.save_cursor();
}
// ESC 8
pub(crate) fn decrc(&mut self) {
self.restore_cursor();
}
// ESC =
pub(crate) fn deckpam(&mut self) {
self.set_mode(MODE_APPLICATION_KEYPAD);
}
// ESC >
pub(crate) fn deckpnm(&mut self) {
self.clear_mode(MODE_APPLICATION_KEYPAD);
}
// ESC M
pub(crate) fn ri(&mut self) {
self.grid_mut().row_dec_scroll(1);
}
// ESC c
pub(crate) fn ris(&mut self) {
*self = Self::new(self.grid.size(), self.grid.scrollback_len());
}
// csi codes
// CSI @
pub(crate) fn ich(&mut self, count: u16) {
self.grid_mut().insert_cells(count);
}
// CSI A
pub(crate) fn cuu(&mut self, offset: u16) {
self.grid_mut().row_dec_clamp(offset);
}
// CSI B
pub(crate) fn cud(&mut self, offset: u16) {
self.grid_mut().row_inc_clamp(offset);
}
// CSI C
pub(crate) fn cuf(&mut self, offset: u16) {
self.grid_mut().col_inc_clamp(offset);
}
// CSI D
pub(crate) fn cub(&mut self, offset: u16) {
self.grid_mut().col_dec(offset);
}
// CSI E
pub(crate) fn cnl(&mut self, offset: u16) {
self.grid_mut().col_set(0);
self.grid_mut().row_inc_clamp(offset);
}
// CSI F
pub(crate) fn cpl(&mut self, offset: u16) {
self.grid_mut().col_set(0);
self.grid_mut().row_dec_clamp(offset);
}
// CSI G
pub(crate) fn cha(&mut self, col: u16) {
self.grid_mut().col_set(col - 1);
}
// CSI H
pub(crate) fn cup(&mut self, (row, col): (u16, u16)) {
self.grid_mut().set_pos(crate::grid::Pos {
row: row - 1,
col: col - 1,
});
}
// CSI J
pub(crate) fn ed(
&mut self,
mode: u16,
mut unhandled: impl FnMut(&mut Self),
) {
let attrs = self.attrs;
match mode {
0 => self.grid_mut().erase_all_forward(attrs),
1 => self.grid_mut().erase_all_backward(attrs),
2 => {
self.grid_mut().erase_all(attrs);
self.check_squelch_signal();
}
3 => {
self.grid_mut().clear_scrollback();
self.check_squelch_signal();
}
_ => unhandled(self),
}
}
// CSI ? J
pub(crate) fn decsed(
&mut self,
mode: u16,
unhandled: impl FnMut(&mut Self),
) {
self.ed(mode, unhandled);
}
// CSI K
pub(crate) fn el(
&mut self,
mode: u16,
mut unhandled: impl FnMut(&mut Self),
) {
let attrs = self.attrs;
match mode {
0 => self.grid_mut().erase_row_forward(attrs),
1 => self.grid_mut().erase_row_backward(attrs),
2 => self.grid_mut().erase_row(attrs),
_ => unhandled(self),
}
}
// CSI ? K
pub(crate) fn decsel(
&mut self,
mode: u16,
unhandled: impl FnMut(&mut Self),
) {
self.el(mode, unhandled);
}
// CSI L
pub(crate) fn il(&mut self, count: u16) {
self.grid_mut().insert_lines(count);
}
// CSI M
pub(crate) fn dl(&mut self, count: u16) {
self.grid_mut().delete_lines(count);
}
// CSI P
pub(crate) fn dch(&mut self, count: u16) {
self.grid_mut().delete_cells(count);
}
// CSI S
pub(crate) fn su(&mut self, count: u16) {
self.grid_mut().scroll_up(count);
}
// CSI T
pub(crate) fn sd(&mut self, count: u16) {
self.grid_mut().scroll_down(count);
}
// CSI X
pub(crate) fn ech(&mut self, count: u16) {
let attrs = self.attrs;
self.grid_mut().erase_cells(count, attrs);
}
// CSI d
pub(crate) fn vpa(&mut self, row: u16) {
self.grid_mut().row_set(row - 1);
}
// CSI ? h
pub(crate) fn decset(
&mut self,
params: &vte::Params,
mut unhandled: impl FnMut(&mut Self),
) {
for param in params {
match param {
[1] => self.set_mode(MODE_APPLICATION_CURSOR),
[6] => self.grid_mut().set_origin_mode(true),
[9] => self.set_mouse_mode(MouseProtocolMode::Press),
[25] => self.clear_mode(MODE_HIDE_CURSOR),
[47] => self.enter_alternate_grid(),
[1000] => {
self.set_mouse_mode(MouseProtocolMode::PressRelease);
}
[1002] => {
self.set_mouse_mode(MouseProtocolMode::ButtonMotion);
}
[1003] => self.set_mouse_mode(MouseProtocolMode::AnyMotion),
[1005] => {
self.set_mouse_encoding(MouseProtocolEncoding::Utf8);
}
[1006] => {
self.set_mouse_encoding(MouseProtocolEncoding::Sgr);
}
[1049] => {
self.decsc();
self.alternate_grid.clear();
self.enter_alternate_grid();
}
[2004] => self.set_mode(MODE_BRACKETED_PASTE),
_ => unhandled(self),
}
}
}
// CSI ? l
pub(crate) fn decrst(
&mut self,
params: &vte::Params,
mut unhandled: impl FnMut(&mut Self),
) {
for param in params {
match param {
[1] => self.clear_mode(MODE_APPLICATION_CURSOR),
[6] => self.grid_mut().set_origin_mode(false),
[9] => self.clear_mouse_mode(MouseProtocolMode::Press),
[25] => self.set_mode(MODE_HIDE_CURSOR),
[47] => {
self.exit_alternate_grid();
}
[1000] => {
self.clear_mouse_mode(MouseProtocolMode::PressRelease);
}
[1002] => {
self.clear_mouse_mode(MouseProtocolMode::ButtonMotion);
}
[1003] => {
self.clear_mouse_mode(MouseProtocolMode::AnyMotion);
}
[1005] => {
self.clear_mouse_encoding(MouseProtocolEncoding::Utf8);
}
[1006] => {
self.clear_mouse_encoding(MouseProtocolEncoding::Sgr);
}
[1049] => {
self.exit_alternate_grid();
self.decrc();
}
[2004] => self.clear_mode(MODE_BRACKETED_PASTE),
_ => unhandled(self),
}
}
}
// CSI m
pub(crate) fn sgr(
&mut self,
params: &vte::Params,
mut unhandled: impl FnMut(&mut Self),
) {
// XXX really i want to just be able to pass in a default Params
// instance with a 0 in it, but vte doesn't allow creating new Params
// instances
if params.is_empty() {
self.attrs = crate::attrs::Attrs::default();
return;
}
let mut iter = params.iter();
macro_rules! next_param {
() => {
match iter.next() {
Some(n) => n,
_ => return,
}
};
}
macro_rules! to_u8 {
($n:expr) => {
if let Some(n) = u16_to_u8($n) {
n
} else {
return;
}
};
}
macro_rules! next_param_u8 {
() => {
if let &[n] = next_param!() {
to_u8!(n)
} else {
return;
}
};
}
loop {
match next_param!() {
[0] => self.attrs = crate::attrs::Attrs::default(),
[1] => self.attrs.set_bold(),
[2] => self.attrs.set_dim(),
[3] => self.attrs.set_italic(true),
[4] => self.attrs.set_underline(true),
[5] | [6] => self.attrs.set_blink(true),
[7] => self.attrs.set_inverse(true),
[8] => self.attrs.set_hidden(true),
[9] => self.attrs.set_strikethrough(true),
[22] => self.attrs.set_normal_intensity(),
[23] => self.attrs.set_italic(false),
[24] => self.attrs.set_underline(false),
[25] => self.attrs.set_blink(false),
[27] => self.attrs.set_inverse(false),
[28] => self.attrs.set_hidden(false),
[29] => self.attrs.set_strikethrough(false),
[n] if (30..=37).contains(n) => {
self.attrs.fgcolor = crate::Color::Idx(to_u8!(*n) - 30);
}
[38, 2, r, g, b] => {
self.attrs.fgcolor =
crate::Color::Rgb(to_u8!(*r), to_u8!(*g), to_u8!(*b));
}
[38, 5, i] => {
self.attrs.fgcolor = crate::Color::Idx(to_u8!(*i));
}
[38] => match next_param!() {
[2] => {
let r = next_param_u8!();
let g = next_param_u8!();
let b = next_param_u8!();
self.attrs.fgcolor = crate::Color::Rgb(r, g, b);
}
[5] => {
self.attrs.fgcolor =
crate::Color::Idx(next_param_u8!());
}
_ => {
unhandled(self);
return;
}
},
[39] => {
self.attrs.fgcolor = crate::Color::Default;
}
[n] if (40..=47).contains(n) => {
self.attrs.bgcolor = crate::Color::Idx(to_u8!(*n) - 40);
}
[48, 2, r, g, b] => {
self.attrs.bgcolor =
crate::Color::Rgb(to_u8!(*r), to_u8!(*g), to_u8!(*b));
}
[48, 5, i] => {
self.attrs.bgcolor = crate::Color::Idx(to_u8!(*i));
}
[48] => match next_param!() {
[2] => {
let r = next_param_u8!();
let g = next_param_u8!();
let b = next_param_u8!();
self.attrs.bgcolor = crate::Color::Rgb(r, g, b);
}
[5] => {
self.attrs.bgcolor =
crate::Color::Idx(next_param_u8!());
}
_ => {
unhandled(self);
return;
}
},
[49] => {
self.attrs.bgcolor = crate::Color::Default;
}
[n] if (90..=97).contains(n) => {
self.attrs.fgcolor = crate::Color::Idx(to_u8!(*n) - 82);
}
[n] if (100..=107).contains(n) => {
self.attrs.bgcolor = crate::Color::Idx(to_u8!(*n) - 92);
}
_ => unhandled(self),
}
}
}
// CSI r
pub(crate) fn decstbm(&mut self, (top, bottom): (u16, u16)) {
self.grid_mut().set_scroll_region(top - 1, bottom - 1);
}
}
fn u16_to_u8(i: u16) -> Option {
if i > u16::from(u8::MAX) {
None
} else {
// safe because we just ensured that the value fits in a u8
Some(i.try_into().unwrap())
}
}
#[cfg(test)]
#[path = "../../../tests-rs/test_vt100_screen.rs"]
mod tests;
#[cfg(test)]
#[path = "../../../tests-rs/test_issue155_sgr_attrs.rs"]
mod test_issue155_sgr_attrs;
================================================
FILE: crates/vt100-psmux/src/term.rs
================================================
// TODO: read all of this from terminfo
pub trait BufWrite {
fn write_buf(&self, buf: &mut Vec);
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct ClearScreen;
impl BufWrite for ClearScreen {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x1b[H\x1b[J");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct ClearRowForward;
impl BufWrite for ClearRowForward {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x1b[K");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct Crlf;
impl BufWrite for Crlf {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\r\n");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct Backspace;
impl BufWrite for Backspace {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x08");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct SaveCursor;
impl BufWrite for SaveCursor {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x1b7");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct RestoreCursor;
impl BufWrite for RestoreCursor {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x1b8");
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct MoveTo {
row: u16,
col: u16,
}
impl MoveTo {
pub fn new(pos: crate::grid::Pos) -> Self {
Self {
row: pos.row,
col: pos.col,
}
}
}
impl BufWrite for MoveTo {
fn write_buf(&self, buf: &mut Vec) {
if self.row == 0 && self.col == 0 {
buf.extend_from_slice(b"\x1b[H");
} else {
buf.extend_from_slice(b"\x1b[");
extend_itoa(buf, self.row + 1);
buf.push(b';');
extend_itoa(buf, self.col + 1);
buf.push(b'H');
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct ClearAttrs;
impl BufWrite for ClearAttrs {
fn write_buf(&self, buf: &mut Vec) {
buf.extend_from_slice(b"\x1b[m");
}
}
#[derive(Debug, Clone, Copy)]
pub enum Intensity {
Normal,
Bold,
Dim,
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct Attrs {
fgcolor: Option,
bgcolor: Option,
intensity: Option,
italic: Option,
underline: Option,
inverse: Option,
blink: Option,
hidden: Option,
strikethrough: Option,
}
impl Attrs {
pub fn fgcolor(mut self, fgcolor: crate::Color) -> Self {
self.fgcolor = Some(fgcolor);
self
}
pub fn bgcolor(mut self, bgcolor: crate::Color) -> Self {
self.bgcolor = Some(bgcolor);
self
}
pub fn intensity(mut self, intensity: Intensity) -> Self {
self.intensity = Some(intensity);
self
}
pub fn italic(mut self, italic: bool) -> Self {
self.italic = Some(italic);
self
}
pub fn underline(mut self, underline: bool) -> Self {
self.underline = Some(underline);
self
}
pub fn inverse(mut self, inverse: bool) -> Self {
self.inverse = Some(inverse);
self
}
pub fn blink(mut self, blink: bool) -> Self {
self.blink = Some(blink);
self
}
pub fn hidden(mut self, hidden: bool) -> Self {
self.hidden = Some(hidden);
self
}
pub fn strikethrough(mut self, strikethrough: bool) -> Self {
self.strikethrough = Some(strikethrough);
self
}
}
impl BufWrite for Attrs {
#[allow(unused_assignments)]
#[allow(clippy::branches_sharing_code)]
fn write_buf(&self, buf: &mut Vec) {
if self.fgcolor.is_none()
&& self.bgcolor.is_none()
&& self.intensity.is_none()
&& self.italic.is_none()
&& self.underline.is_none()
&& self.inverse.is_none()
&& self.blink.is_none()
&& self.hidden.is_none()
&& self.strikethrough.is_none()
{
return;
}
buf.extend_from_slice(b"\x1b[");
let mut first = true;
macro_rules! write_param {
($i:expr) => {{
if first {
first = false;
} else {
buf.push(b';');
}
extend_itoa(buf, $i);
}};
}
if let Some(fgcolor) = self.fgcolor {
match fgcolor {
crate::Color::Default => {
write_param!(39);
}
crate::Color::Idx(i) => {
if i < 8 {
write_param!(i + 30);
} else if i < 16 {
write_param!(i + 82);
} else {
write_param!(38);
write_param!(5);
write_param!(i);
}
}
crate::Color::Rgb(r, g, b) => {
write_param!(38);
write_param!(2);
write_param!(r);
write_param!(g);
write_param!(b);
}
}
}
if let Some(bgcolor) = self.bgcolor {
match bgcolor {
crate::Color::Default => {
write_param!(49);
}
crate::Color::Idx(i) => {
if i < 8 {
write_param!(i + 40);
} else if i < 16 {
write_param!(i + 92);
} else {
write_param!(48);
write_param!(5);
write_param!(i);
}
}
crate::Color::Rgb(r, g, b) => {
write_param!(48);
write_param!(2);
write_param!(r);
write_param!(g);
write_param!(b);
}
}
}
if let Some(intensity) = self.intensity {
match intensity {
Intensity::Normal => write_param!(22),
Intensity::Bold => write_param!(1),
Intensity::Dim => write_param!(2),
}
}
if let Some(italic) = self.italic {
if italic {
write_param!(3);
} else {
write_param!(23);
}
}
if let Some(underline) = self.underline {
if underline {
write_param!(4);
} else {
write_param!(24);
}
}
if let Some(inverse) = self.inverse {
if inverse {
write_param!(7);
} else {
write_param!(27);
}
}
if let Some(blink) = self.blink {
if blink {
write_param!(5);
} else {
write_param!(25);
}
}
if let Some(hidden) = self.hidden {
if hidden {
write_param!(8);
} else {
write_param!(28);
}
}
if let Some(strikethrough) = self.strikethrough {
if strikethrough {
write_param!(9);
} else {
write_param!(29);
}
}
buf.push(b'm');
}
}
#[derive(Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct MoveRight {
count: u16,
}
impl MoveRight {
pub fn new(count: u16) -> Self {
Self { count }
}
}
impl Default for MoveRight {
fn default() -> Self {
Self { count: 1 }
}
}
impl BufWrite for MoveRight {
fn write_buf(&self, buf: &mut Vec) {
match self.count {
0 => {}
1 => buf.extend_from_slice(b"\x1b[C"),
n => {
buf.extend_from_slice(b"\x1b[");
extend_itoa(buf, n);
buf.push(b'C');
}
}
}
}
#[derive(Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct EraseChar {
count: u16,
}
impl EraseChar {
pub fn new(count: u16) -> Self {
Self { count }
}
}
impl Default for EraseChar {
fn default() -> Self {
Self { count: 1 }
}
}
impl BufWrite for EraseChar {
fn write_buf(&self, buf: &mut Vec) {
match self.count {
0 => {}
1 => buf.extend_from_slice(b"\x1b[X"),
n => {
buf.extend_from_slice(b"\x1b[");
extend_itoa(buf, n);
buf.push(b'X');
}
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct HideCursor {
state: bool,
}
impl HideCursor {
pub fn new(state: bool) -> Self {
Self { state }
}
}
impl BufWrite for HideCursor {
fn write_buf(&self, buf: &mut Vec) {
if self.state {
buf.extend_from_slice(b"\x1b[?25l");
} else {
buf.extend_from_slice(b"\x1b[?25h");
}
}
}
#[derive(Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct MoveFromTo {
from: crate::grid::Pos,
to: crate::grid::Pos,
}
impl MoveFromTo {
pub fn new(from: crate::grid::Pos, to: crate::grid::Pos) -> Self {
Self { from, to }
}
}
impl BufWrite for MoveFromTo {
fn write_buf(&self, buf: &mut Vec) {
if self.to.row == self.from.row + 1 && self.to.col == 0 {
crate::term::Crlf.write_buf(buf);
} else if self.from.row == self.to.row && self.from.col < self.to.col
{
crate::term::MoveRight::new(self.to.col - self.from.col)
.write_buf(buf);
} else if self.to != self.from {
crate::term::MoveTo::new(self.to).write_buf(buf);
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct ApplicationKeypad {
state: bool,
}
impl ApplicationKeypad {
pub fn new(state: bool) -> Self {
Self { state }
}
}
impl BufWrite for ApplicationKeypad {
fn write_buf(&self, buf: &mut Vec) {
if self.state {
buf.extend_from_slice(b"\x1b=");
} else {
buf.extend_from_slice(b"\x1b>");
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct ApplicationCursor {
state: bool,
}
impl ApplicationCursor {
pub fn new(state: bool) -> Self {
Self { state }
}
}
impl BufWrite for ApplicationCursor {
fn write_buf(&self, buf: &mut Vec) {
if self.state {
buf.extend_from_slice(b"\x1b[?1h");
} else {
buf.extend_from_slice(b"\x1b[?1l");
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct BracketedPaste {
state: bool,
}
impl BracketedPaste {
pub fn new(state: bool) -> Self {
Self { state }
}
}
impl BufWrite for BracketedPaste {
fn write_buf(&self, buf: &mut Vec) {
if self.state {
buf.extend_from_slice(b"\x1b[?2004h");
} else {
buf.extend_from_slice(b"\x1b[?2004l");
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct MouseProtocolMode {
mode: crate::MouseProtocolMode,
prev: crate::MouseProtocolMode,
}
impl MouseProtocolMode {
pub fn new(
mode: crate::MouseProtocolMode,
prev: crate::MouseProtocolMode,
) -> Self {
Self { mode, prev }
}
}
impl BufWrite for MouseProtocolMode {
fn write_buf(&self, buf: &mut Vec) {
if self.mode == self.prev {
return;
}
match self.mode {
crate::MouseProtocolMode::None => match self.prev {
crate::MouseProtocolMode::None => {}
crate::MouseProtocolMode::Press => {
buf.extend_from_slice(b"\x1b[?9l");
}
crate::MouseProtocolMode::PressRelease => {
buf.extend_from_slice(b"\x1b[?1000l");
}
crate::MouseProtocolMode::ButtonMotion => {
buf.extend_from_slice(b"\x1b[?1002l");
}
crate::MouseProtocolMode::AnyMotion => {
buf.extend_from_slice(b"\x1b[?1003l");
}
},
crate::MouseProtocolMode::Press => {
buf.extend_from_slice(b"\x1b[?9h");
}
crate::MouseProtocolMode::PressRelease => {
buf.extend_from_slice(b"\x1b[?1000h");
}
crate::MouseProtocolMode::ButtonMotion => {
buf.extend_from_slice(b"\x1b[?1002h");
}
crate::MouseProtocolMode::AnyMotion => {
buf.extend_from_slice(b"\x1b[?1003h");
}
}
}
}
#[derive(Default, Debug)]
#[must_use = "this struct does nothing unless you call write_buf"]
pub struct MouseProtocolEncoding {
encoding: crate::MouseProtocolEncoding,
prev: crate::MouseProtocolEncoding,
}
impl MouseProtocolEncoding {
pub fn new(
encoding: crate::MouseProtocolEncoding,
prev: crate::MouseProtocolEncoding,
) -> Self {
Self { encoding, prev }
}
}
impl BufWrite for MouseProtocolEncoding {
fn write_buf(&self, buf: &mut Vec) {
if self.encoding == self.prev {
return;
}
match self.encoding {
crate::MouseProtocolEncoding::Default => match self.prev {
crate::MouseProtocolEncoding::Default => {}
crate::MouseProtocolEncoding::Utf8 => {
buf.extend_from_slice(b"\x1b[?1005l");
}
crate::MouseProtocolEncoding::Sgr => {
buf.extend_from_slice(b"\x1b[?1006l");
}
},
crate::MouseProtocolEncoding::Utf8 => {
buf.extend_from_slice(b"\x1b[?1005h");
}
crate::MouseProtocolEncoding::Sgr => {
buf.extend_from_slice(b"\x1b[?1006h");
}
}
}
}
fn extend_itoa(buf: &mut Vec, i: I) {
let mut itoa_buf = itoa::Buffer::new();
buf.extend_from_slice(itoa_buf.format(i).as_bytes());
}
================================================
FILE: docker/Dockerfile
================================================
# escape=`
# psmux build environment: Windows + Rust (MSVC) + OpenSSH (key-only auth)
#
# Build: docker build -t psmux-dev .
# Run: pwsh -File docker\Run-PsmuxDev.ps1
# SSH: (printed by Run-PsmuxDev.ps1 after container starts)
#
FROM mcr.microsoft.com/powershell:windowsservercore-ltsc2022
# NOTE: We keep the default cmd shell for RUN steps and invoke pwsh explicitly.
# Using SHELL ["pwsh",...] breaks Hyper-V isolated builds on Win11 25H2 hosts.
ENV VS_INSTALL_PATH="C:\BuildTools"
ENV RUSTUP_HOME="C:\rustup"
ENV CARGO_HOME="C:\cargo"
ENV PATH="C:\cargo\bin;C:\OpenSSH;C:\git\cmd;C:\Windows\System32;C:\Windows;C:\Program Files\PowerShell\latest;${PATH}"
# Create directories and copy all scripts first
RUN mkdir C:\Tools && mkdir C:\Profile && mkdir "C:\Users\ContainerAdministrator\Documents\PowerShell"
COPY Tools\ C:\Tools\
COPY Profile\ C:\Users\ContainerAdministrator\Documents\PowerShell\
# Single combined install step: Rust + VS Build Tools + OpenSSH + Git + cleanup
# (Combined to avoid Docker layer commit failures from VS Build Tools long paths)
RUN pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\Tools\InstallAll.ps1
EXPOSE 2222
CMD ["pwsh", "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Tools\\StartContainer.ps1"]
================================================
FILE: docker/Profile/Microsoft.PowerShell_profile.ps1
================================================
$ErrorActionPreference = "Continue"
# Ensure Cargo bin is on PATH
if ($env:CARGO_HOME -and (Test-Path "$env:CARGO_HOME\bin")) {
if ($env:PATH -notlike "*$env:CARGO_HOME\bin*") {
$env:PATH = "$env:CARGO_HOME\bin;$env:PATH"
}
}
# Ensure git is on PATH
if (Test-Path "C:\git\cmd") {
if ($env:PATH -notlike "*C:\git\cmd*") {
$env:PATH = "C:\git\cmd;$env:PATH"
}
}
# Auto-load VS dev environment (cl.exe, link.exe) if not already loaded
if (-not $env:VSCMD_VER) {
$helper = "C:\Tools\ImportVsDevEnv.ps1"
if (Test-Path $helper) {
. $helper
}
}
================================================
FILE: docker/README.md
================================================
# psmux Docker Dev Environment
A Windows container with Rust (MSVC), Visual Studio Build Tools, and OpenSSH — ready to build and run psmux.
## What's inside
| Component | Details |
|-----------|---------|
| Base image | `mcr.microsoft.com/powershell:windowsservercore-ltsc2022` |
| Rust | stable-x86_64-pc-windows-msvc via rustup |
| MSVC | Visual Studio Build Tools 2022 (`cl.exe`, `link.exe`) |
| SSH | OpenSSH Server on port 2222 (key-only auth, no passwords) |
| Shell | PowerShell 7 with auto-loaded VS dev environment |
| Git | MinGit for cloning repos |
## Quick start
### One command
```powershell
pwsh -File docker\Run-PsmuxDev.ps1
```
This will:
1. Generate an SSH key at `~/.ssh/psmux_docker_key` (if not present)
2. Build the Docker image (first time only)
3. Start the container with your public key injected
4. Print the SSH command to connect
### Manual steps
#### 1. Build the image
```powershell
cd docker
docker build -t psmux-dev .
```
> **Note:** The build takes a while (~15-30 min) because it downloads and installs Visual Studio Build Tools. The resulting image is large (~15 GB). This is expected for Windows MSVC containers.
#### 2. Run the container
```powershell
# Generate SSH key (once)
ssh-keygen -t ed25519 -f ~/.ssh/psmux_docker_key -N "" -C "psmux-docker"
# Run with your public key
$pubkey = Get-Content ~/.ssh/psmux_docker_key.pub
docker run -d --name psmux-dev --isolation=hyperv `
-e "SSH_PUBLIC_KEY=$pubkey" `
psmux-dev
```
#### 3. SSH in
```powershell
$ip = docker inspect psmux-dev --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
ssh -i ~/.ssh/psmux_docker_key -p 2222 ContainerAdministrator@$ip
```
### 4. Build psmux
```powershell
git clone https://github.com/psmux/psmux.git
cd psmux
cargo install --path .
psmux --version
```
## SSH authentication
This container uses **key-only SSH** — no passwords. Your public key is passed in via the `SSH_PUBLIC_KEY` environment variable at container start. The `Run-PsmuxDev.ps1` script handles this automatically.
You can also mount a public key file:
```powershell
docker run -d --name psmux-dev --isolation=hyperv `
-v "$HOME\.ssh\id_ed25519.pub:C:\ssh_public_key" `
psmux-dev
```
## Verifying the toolchain
After SSH-ing in, these should all work:
```powershell
rustc --version
cargo --version
where cl
where link
```
## Safety notes
- No passwords are used — SSH key auth only
- Container runs with Hyper-V isolation (full VM separation from host)
- SSH listens on port 2222 to avoid conflicts with host sshd
- Key is stored at `~/.ssh/psmux_docker_key` (never inside the repo)
## File layout
```
docker/
Dockerfile
README.md
Run-PsmuxDev.ps1 # Host-side: generates key, builds, runs, prints SSH command
Tools/
StartContainer.ps1 # Entrypoint: configures sshd with key auth, starts sshd
InstallAll.ps1 # Build-time: installs Rust, VS Build Tools, OpenSSH, Git
ImportVsDevEnv.ps1 # Loads VS dev environment (cl.exe, link.exe) into PowerShell
Profile/
Microsoft.PowerShell_profile.ps1 # Auto-loads Rust + MSVC env on every shell
```
================================================
FILE: docker/Run-PsmuxDev.ps1
================================================
<#
.SYNOPSIS
Build (if needed) and run the psmux-dev Docker container with SSH key auth.
.DESCRIPTION
- Generates an SSH keypair in ~/.ssh/psmux_docker_key (if not present)
- Builds the psmux-dev image (if not present)
- Starts the container with your public key injected
- Prints the SSH command to connect
.EXAMPLE
pwsh -File docker\Run-PsmuxDev.ps1
pwsh -File docker\Run-PsmuxDev.ps1 -Rebuild
#>
param(
[switch]$Rebuild
)
$ErrorActionPreference = "Stop"
$imageName = "psmux-dev"
$containerName = "psmux-dev"
$keyPath = Join-Path $env:USERPROFILE ".ssh\psmux_docker_key"
$pubKeyPath = "$keyPath.pub"
$dockerDir = $PSScriptRoot # docker/ folder
# ── 1. Generate SSH key if missing ──
if (-not (Test-Path $keyPath)) {
Write-Host "Generating SSH key at $keyPath ..."
$sshDir = Split-Path $keyPath
if (-not (Test-Path $sshDir)) { New-Item -ItemType Directory -Path $sshDir -Force | Out-Null }
ssh-keygen -t ed25519 -f $keyPath -N "" -C "psmux-docker" -q
Write-Host " Key generated."
} else {
Write-Host "Using existing SSH key: $keyPath"
}
$pubKey = (Get-Content $pubKeyPath -Raw).Trim()
Write-Host " Public key: $($pubKey.Substring(0, [Math]::Min(60, $pubKey.Length)))..."
# ── 2. Build image if needed ──
$imageExists = docker images $imageName -q 2>$null
if (-not $imageExists -or $Rebuild) {
Write-Host ""
Write-Host "Building $imageName image (this takes a while on first run)..."
docker build -t $imageName $dockerDir
if ($LASTEXITCODE -ne 0) { throw "Docker build failed" }
}
# ── 3. Remove old container if exists ──
$existing = docker ps -aq -f "name=$containerName" 2>$null
if ($existing) {
Write-Host "Removing existing container..."
docker rm -f $containerName 2>$null | Out-Null
}
# ── 4. Run container with public key ──
Write-Host "Starting container..."
docker run -d `
--name $containerName `
--isolation=hyperv `
-e "SSH_PUBLIC_KEY=$pubKey" `
$imageName | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Docker run failed" }
# Wait for container to initialize
Write-Host "Waiting for sshd to start..."
Start-Sleep 5
# ── 5. Get container IP ──
$containerIP = docker inspect $containerName --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
if (-not $containerIP) { throw "Could not get container IP" }
# ── 6. Print connection info ──
Write-Host ""
Write-Host "============================================" -ForegroundColor Green
Write-Host " psmux dev container is running" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Green
Write-Host ""
Write-Host " Connect:" -ForegroundColor Cyan
Write-Host " ssh -i ~/.ssh/psmux_docker_key -p 2222 ContainerAdministrator@$containerIP"
Write-Host ""
Write-Host " Quick build:" -ForegroundColor Cyan
Write-Host " git clone https://github.com/psmux/psmux.git"
Write-Host " cd psmux && cargo install --path ."
Write-Host ""
Write-Host " Stop:" -ForegroundColor Cyan
Write-Host " docker stop $containerName"
Write-Host ""
Write-Host " Restart:" -ForegroundColor Cyan
Write-Host " docker start $containerName"
Write-Host ""
Write-Host "============================================" -ForegroundColor Green
# ── 7. Verify SSH connectivity ──
Write-Host ""
Write-Host "Testing SSH connection..."
$result = ssh -i $keyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o ConnectTimeout=10 -p 2222 ContainerAdministrator@$containerIP "hostname" 2>$null
if ($result) {
Write-Host " Connected to: $result" -ForegroundColor Green
} else {
Write-Host " SSH not ready yet — container may still be initializing." -ForegroundColor Yellow
Write-Host " Try manually: ssh -i ~/.ssh/psmux_docker_key -p 2222 ContainerAdministrator@$containerIP"
}
================================================
FILE: docker/Tools/ImportVsDevEnv.ps1
================================================
param(
[string]$VsInstallPath = $env:VS_INSTALL_PATH
)
$ErrorActionPreference = "Stop"
$vsDevCmd = Join-Path $VsInstallPath "Common7\Tools\VsDevCmd.bat"
if (-not (Test-Path $vsDevCmd)) {
throw "VsDevCmd.bat not found: $vsDevCmd"
}
cmd /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 && set" | ForEach-Object {
if ($_ -match "^(.*?)=(.*)$") {
Set-Item -Path "Env:\$($matches[1])" -Value $matches[2]
}
}
================================================
FILE: docker/Tools/InstallAll.ps1
================================================
$ErrorActionPreference = "Continue"
# ============================================
# 0. Download everything first (before heavy installs use up disk/memory)
# ============================================
Write-Host "=== Downloading all installers ==="
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Write-Host " Downloading Rust..."
Invoke-WebRequest https://win.rustup.rs -OutFile C:\rustup-init.exe -UseBasicParsing
Write-Host " Downloading VS Build Tools..."
Invoke-WebRequest https://aka.ms/vs/17/release/vs_BuildTools.exe -OutFile C:\vs_BuildTools.exe -UseBasicParsing
Write-Host " Downloading OpenSSH..."
try {
Invoke-WebRequest "https://github.com/PowerShell/Win32-OpenSSH/releases/download/v9.8.3.0p2-Preview/OpenSSH-Win64.zip" -OutFile C:\openssh.zip -UseBasicParsing
Write-Host " OpenSSH downloaded: $((Get-Item C:\openssh.zip).Length) bytes"
} catch {
Write-Host " OpenSSH download error: $($_.Exception.Message)"
exit 1
}
Write-Host " Downloading Git..."
try {
Invoke-WebRequest "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/MinGit-2.47.1.2-64-bit.zip" -OutFile C:\git.zip -UseBasicParsing
Write-Host " Git downloaded: $((Get-Item C:\git.zip).Length) bytes"
} catch {
Write-Host " Git download error: $($_.Exception.Message)"
exit 1
}
Write-Host "All downloads complete."
# ============================================
# 1. Install Rust
# ============================================
Write-Host "=== Installing Rust ==="
Start-Process -FilePath C:\rustup-init.exe -ArgumentList "-y" -Wait
Remove-Item C:\rustup-init.exe -Force
& C:\cargo\bin\rustup.exe default stable-x86_64-pc-windows-msvc
& C:\cargo\bin\rustc.exe --version
& C:\cargo\bin\cargo.exe --version
# ============================================
# 2. Install Visual Studio Build Tools
# ============================================
Write-Host "=== Installing Visual Studio Build Tools ==="
$proc = Start-Process -FilePath C:\vs_BuildTools.exe -ArgumentList @(
"--quiet","--wait","--norestart","--nocache",
"--installPath","C:\BuildTools",
"--add","Microsoft.VisualStudio.Workload.VCTools",
"--add","Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"--add","Microsoft.VisualStudio.Component.Windows10SDK.19041"
) -Wait -PassThru
Write-Host "VS Build Tools exit code: $($proc.ExitCode)"
Remove-Item C:\vs_BuildTools.exe -Force -ErrorAction SilentlyContinue
if (-not (Test-Path "C:\BuildTools\VC\Tools\MSVC")) {
throw "MSVC toolset missing after install"
}
Write-Host "VS Build Tools installed."
# Clean up VS installer temp immediately to free disk space
Remove-Item "C:\Users\ContainerAdministrator\AppData\Local\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\ProgramData\Package Cache" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\BuildTools\Installer" -Recurse -Force -ErrorAction SilentlyContinue
# ============================================
# 3. Install OpenSSH Server
# ============================================
Write-Host "=== Installing OpenSSH Server ==="
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("C:\openssh.zip", "C:\sshtmp")
Remove-Item C:\openssh.zip -Force
# Move extracted dir (OpenSSH-Win64) to C:\OpenSSH
$extracted = Get-ChildItem "C:\sshtmp" -Directory | Select-Object -First 1
if ($extracted) {
Move-Item $extracted.FullName "C:\OpenSSH" -Force
} else {
Move-Item "C:\sshtmp" "C:\OpenSSH" -Force
}
Remove-Item "C:\sshtmp" -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "C:\OpenSSH\sshd.exe") {
Write-Host "OpenSSH Server installed to C:\OpenSSH"
} else {
throw "sshd.exe not found after OpenSSH install"
}
# ============================================
# 4. Install Git
# ============================================
Write-Host "=== Installing Git ==="
Expand-Archive C:\git.zip -DestinationPath C:\git -Force
Remove-Item C:\git.zip -Force
[Environment]::SetEnvironmentVariable("PATH", "C:\git\cmd;" + [Environment]::GetEnvironmentVariable("PATH","Machine"), "Machine")
Write-Host "Git installed."
# ============================================
# 5. Aggressive cleanup to avoid Docker layer commit failures
# ============================================
Write-Host "=== Cleanup ==="
# VS Build Tools installer caches and temp files with very long paths
Remove-Item "C:\Users\ContainerAdministrator\AppData\Local\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\ProgramData\Package Cache" -Recurse -Force -ErrorAction SilentlyContinue
# Remove VS installer metadata (keeps long path dirs)
Remove-Item "C:\BuildTools\Installer" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\ProgramData\Microsoft\VisualStudio" -Recurse -Force -ErrorAction SilentlyContinue
# Remove NuGet cache
Remove-Item "C:\Users\ContainerAdministrator\.nuget" -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "=== All installations complete ==="
================================================
FILE: docker/Tools/InstallGit.ps1
================================================
$ErrorActionPreference = "Stop"
$url = "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/MinGit-2.47.1.2-64-bit.zip"
Write-Host "Downloading MinGit..."
Invoke-WebRequest $url -OutFile C:\git.zip
Write-Host "Extracting..."
Expand-Archive C:\git.zip -DestinationPath C:\git -Force
Remove-Item C:\git.zip -Force
[Environment]::SetEnvironmentVariable("PATH", "C:\git\cmd;" + [Environment]::GetEnvironmentVariable("PATH", "Machine"), "Machine")
Write-Host "Git installed."
================================================
FILE: docker/Tools/InstallOpenSSH.ps1
================================================
$ErrorActionPreference = "Stop"
$url = "https://github.com/PowerShell/Win32-OpenSSH/releases/download/v9.8.3.0p2-Preview/OpenSSH-Win64.zip"
Write-Host "Downloading Win32-OpenSSH..."
Invoke-WebRequest $url -OutFile C:\openssh.zip -UseBasicParsing
Write-Host "Extracting..."
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("C:\openssh.zip", "C:\sshtmp")
Remove-Item C:\openssh.zip -Force
$extracted = Get-ChildItem "C:\sshtmp" -Directory | Select-Object -First 1
if ($extracted) {
Move-Item $extracted.FullName "C:\OpenSSH" -Force
} else {
Move-Item "C:\sshtmp" "C:\OpenSSH" -Force
}
Remove-Item "C:\sshtmp" -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "C:\OpenSSH\sshd.exe") {
Write-Host "OpenSSH Server installed to C:\OpenSSH"
} else {
throw "sshd.exe not found after OpenSSH install"
}
================================================
FILE: docker/Tools/InstallRust.ps1
================================================
$ErrorActionPreference = "Stop"
Write-Host "Downloading rustup..."
Invoke-WebRequest https://win.rustup.rs -OutFile C:\rustup-init.exe
Write-Host "Installing Rust..."
Start-Process -FilePath C:\rustup-init.exe -ArgumentList "-y" -Wait
Remove-Item C:\rustup-init.exe -Force
& C:\cargo\bin\rustup.exe default stable-x86_64-pc-windows-msvc
& C:\cargo\bin\rustc.exe --version
& C:\cargo\bin\cargo.exe --version
Write-Host "Rust installed."
================================================
FILE: docker/Tools/InstallVsBuildTools.ps1
================================================
$ErrorActionPreference = "Stop"
$installPath = "C:\BuildTools"
$logFile = "C:\vsbuildtools-install.log"
Write-Host "Downloading Visual Studio Build Tools..."
Invoke-WebRequest https://aka.ms/vs/17/release/vs_BuildTools.exe -OutFile C:\vs_BuildTools.exe
Write-Host "Installing Visual Studio Build Tools (this takes a while)..."
$proc = Start-Process -FilePath C:\vs_BuildTools.exe -ArgumentList @(
"--quiet","--wait","--norestart","--nocache",
"--installPath", $installPath,
"--add","Microsoft.VisualStudio.Workload.VCTools",
"--add","Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"--add","Microsoft.VisualStudio.Component.Windows10SDK.19041"
) -Wait -PassThru
Write-Host "Installer exit code: $($proc.ExitCode)"
Remove-Item C:\vs_BuildTools.exe -Force -ErrorAction SilentlyContinue
if (-not (Test-Path "$installPath\VC\Tools\MSVC")) {
Write-Host "Build Tools install FAILED."
# Try to find log files
Get-ChildItem "C:\Users\ContainerAdministrator\AppData\Local\Temp" -Filter "dd_*.log" -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host "=== $($_.Name) (tail) ==="
Get-Content $_.FullName -Tail 50
}
throw "MSVC toolset missing after install (exit code: $($proc.ExitCode))"
}
Write-Host "Visual Studio Build Tools installed successfully."
# Clean up installer temp files (extremely long paths break Docker layer commit)
Write-Host "Cleaning up temp files..."
Remove-Item "C:\Users\ContainerAdministrator\AppData\Local\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
# Remove installer cache & logs
Remove-Item "C:\ProgramData\Package Cache\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\BuildTools\Installer" -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "Cleanup done."
================================================
FILE: docker/Tools/StartContainer.ps1
================================================
$ErrorActionPreference = "Stop"
$opensshDir = "C:\OpenSSH"
$sshdConfig = "C:\ProgramData\ssh\sshd_config"
$adminKeysFile = "C:\ProgramData\ssh\administrators_authorized_keys"
# ── Ensure critical system paths and OpenSSH are on PATH ──
# (docker commit can lose System32 from PATH depending on how ENV was set)
# Use exact-match check to avoid substring false positives (e.g. System32\OpenSSH)
$pathEntries = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($p in @("C:\Windows\System32", "C:\Windows", $opensshDir, "C:\git\cmd")) {
if ($p.TrimEnd('\') -notin $pathEntries) { $env:PATH = "$p;$env:PATH" }
}
# Also persist to machine-level so SSH sessions inherit the full PATH
[Environment]::SetEnvironmentVariable("PATH", $env:PATH, "Machine")
# ── Set default shell to PowerShell ──
New-Item -Path "HKLM:\SOFTWARE\OpenSSH" -Force | Out-Null
$pwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
if (-not $pwshPath) { $pwshPath = "C:\Program Files\PowerShell\7\pwsh.exe" }
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name "DefaultShell" -Value $pwshPath -PropertyType String -Force | Out-Null
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name "DefaultShellCommandOption" -Value "-c" -PropertyType String -Force | Out-Null
# ── Generate host keys if missing ──
New-Item -ItemType Directory -Path "C:\ProgramData\ssh" -Force | Out-Null
if (-not (Test-Path "C:\ProgramData\ssh\ssh_host_ed25519_key")) {
& "$opensshDir\ssh-keygen.exe" -A 2>$null
}
# ── Write sshd_config: key-only auth, port 2222 ──
@"
Port 2222
ListenAddress 0.0.0.0
HostKey C:/ProgramData/ssh/ssh_host_rsa_key
HostKey C:/ProgramData/ssh/ssh_host_ecdsa_key
HostKey C:/ProgramData/ssh/ssh_host_ed25519_key
PasswordAuthentication no
PubkeyAuthentication yes
StrictModes no
Subsystem sftp C:/OpenSSH/sftp-server.exe
Match Group administrators
AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys
"@ | Set-Content -Path $sshdConfig -Encoding ascii
# ── Install authorized public key ──
# Accepts key via: SSH_PUBLIC_KEY env var, or mounted file at C:\ssh_public_key
$pubkey = $env:SSH_PUBLIC_KEY
if (-not $pubkey -and (Test-Path "C:\ssh_public_key")) {
$pubkey = (Get-Content "C:\ssh_public_key" -Raw).Trim()
}
if (-not $pubkey) {
Write-Host ""
Write-Host "ERROR: No SSH public key provided." -ForegroundColor Red
Write-Host "Pass your public key via one of:"
Write-Host ' -e SSH_PUBLIC_KEY="ssh-ed25519 AAAA..."'
Write-Host ' -v C:\Users\you\.ssh\id_ed25519.pub:C:\ssh_public_key'
Write-Host ""
exit 1
}
Set-Content -Path $adminKeysFile -Value $pubkey -Encoding ascii
# Note: StrictModes is disabled in sshd_config, so we don't need to set
# restrictive ACLs on administrators_authorized_keys. Setting ACLs via
# icacls or Set-Acl crashes Hyper-V isolated containers.
# ── Start sshd directly (not as a service) ──
$sshdProc = Start-Process -FilePath "$opensshDir\sshd.exe" `
-ArgumentList "-f", $sshdConfig `
-PassThru -WindowStyle Hidden
Start-Sleep 2
if ($sshdProc.HasExited) {
Write-Host "ERROR: sshd failed to start (exit code $($sshdProc.ExitCode))" -ForegroundColor Red
& "$opensshDir\sshd.exe" -f $sshdConfig -d -d 2>&1 | Select-Object -First 20
exit 1
}
# ── Print connection info ──
# Get-NetIPAddress isn't available in windowsservercore containers; use .NET instead
$ip = ([System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) |
Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.ToString() -ne '127.0.0.1' } |
Select-Object -First 1).ToString()
Write-Host ""
Write-Host "============================================"
Write-Host " psmux dev container ready" -ForegroundColor Green
Write-Host "============================================"
Write-Host " SSH : ssh -i ~/.ssh/psmux_docker_key -p 2222 ContainerAdministrator@$ip"
Write-Host " Rust : $(& rustc --version 2>$null)"
Write-Host " Cargo: $(& cargo --version 2>$null)"
Write-Host "============================================"
Write-Host ""
Write-Host "Quick start:"
Write-Host " git clone https://github.com/psmux/psmux.git"
Write-Host " cd psmux"
Write-Host " cargo install --path ."
Write-Host ""
# Keep container alive + restart sshd if it crashes
while ($true) {
Start-Sleep -Seconds 30
if ($sshdProc.HasExited) {
Write-Host "sshd exited, restarting..."
$sshdProc = Start-Process -FilePath "$opensshDir\sshd.exe" `
-ArgumentList "-f", $sshdConfig `
-PassThru -WindowStyle Hidden
}
}
================================================
FILE: docs/claude-code.md
================================================
# Claude Code Agent Teams
psmux has first-class support for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) agent teams. When Claude Code runs inside a psmux session, it automatically spawns teammate agents in separate tmux panes instead of running them in-process — giving you full visibility into what each agent is doing.
## Prerequisites
### PowerShell 7+
[Install PowerShell 7 on Windows](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.6)
To work with Claude Code, psmux **requires PowerShell 7 or later**. The env shim and teammate mode injection rely on PowerShell 7+ features that are not available in the legacy Windows PowerShell 5.1.
Check your current version:
```powershell
$PSVersionTable.PSVersion
```
If you are on an older version, install PowerShell 7+ via winget:
```powershell
winget install --id Microsoft.PowerShell --source winget
```
After installation, restart your terminal and verify the version again.
- `pwsh` will run the new version
- `powershell` will still run the older legacy version as a fallback
You may need to restart VS Code for changes to the default terminal to take effect.
> **Credit:** This prerequisite documentation was contributed by [@LiamKarlMitchell](https://github.com/LiamKarlMitchell) in [#184](https://github.com/psmux/psmux/pull/184) after discovering the PowerShell version requirement while troubleshooting [#173](https://github.com/psmux/psmux/issues/173).
## Quick Start
1. **Install psmux** (see [README](../README.md#installation))
2. **Start a psmux session:**
```powershell
psmux new-session -s work
```
3. **Run Claude Code inside the psmux pane:**
```powershell
claude
```
4. **Ask Claude to create a team.** Claude Code will automatically split panes for each teammate agent.
That's it. No extra configuration needed — psmux handles everything automatically.
## How It Works
When a pane spawns inside psmux, several environment variables are set automatically:
| Variable | Value | Purpose |
|----------|-------|---------|
| `TMUX` | `/tmp/psmux-{pid}/...` | Tells Claude Code it's inside tmux |
| `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` | `1` | Enables the agent teams feature gate |
| `PSMUX_CLAUDE_TEAMMATE_MODE` | `tmux` | Triggers the `--teammate-mode tmux` CLI injection |
Claude Code detects the `TMUX` environment variable, recognizes it's inside a tmux-compatible multiplexer, and uses the **TmuxBackend** to spawn teammate agents via `split-window` and `send-keys` — the same mechanism it uses on Linux/macOS tmux.
### The Two Things psmux Fixes
Claude Code's standalone binary (the Bun SFE `claude.exe`) has two issues on Windows that psmux works around:
1. **Agent teams feature gate**: The entire teammate tool-set (spawnTeam, spawnTeammate) is gated behind `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. Without this env var, Claude only has the in-process "Agent" tool and never creates separate panes. psmux sets this automatically.
2. **`teammateMode` config ignored**: The standalone binary ignores `teammateMode: "tmux"` from `~/.claude/settings.json`. psmux injects `--teammate-mode tmux` via a PowerShell wrapper function that's loaded in every pane.
## Configuration Options
These options can be set in `~/.psmux.conf` or at runtime:
```tmux
# Auto-inject --teammate-mode tmux for Claude Code (default: on)
set -g claude-code-fix-tty on
# Disable the Claude Code teammate-mode workaround
set -g claude-code-fix-tty off
```
### What each option controls
| Option | Default | Description |
|--------|---------|-------------|
| `claude-code-fix-tty` | `on` | Sets `PSMUX_CLAUDE_TEAMMATE_MODE=tmux` and defines a `claude` wrapper function that injects `--teammate-mode tmux` into every `claude` invocation |
The `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var is always set (not gated by any option) since it's required for the feature to work at all.
## Two Agent Systems in Claude Code
Claude Code has **two completely separate agent systems**. Understanding both is critical because psmux can only control one of them.
### 1. Teammate Agents (tmux panes) ✅
The **teammate system** spawns agents in visible tmux panes. This is the system psmux fully supports.
- Triggered when the model passes `team_name` + `name` to the subagent tool
- Gated by `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (psmux sets this)
- Controlled by `--teammate-mode tmux` (psmux injects this)
- Each agent gets its own pane with full terminal visibility
- Lower-tier models (Haiku, Sonnet) tend to prefer this path
### 2. Worktree Agents (in-process, invisible) ⚠️
The **worktree system** creates isolated git worktrees and runs agents in-process — **invisible to the user**.
- Triggered when the model passes `isolation: "worktree"` to the subagent tool
- Creates git worktrees at `.claude/worktrees/agent-/` via `git worktree add`
- Each agent works on a separate branch in an isolated repo copy
- Runs entirely in-process (no pane, no terminal output visible)
- Higher-tier models (Opus) tend to prefer this path for git-level isolation
- **On Windows, worktree tmux integration is hardcoded disabled** (`"--tmux may not have effect on Windows when model chooses worktrees. Opus tends to always choose that."`)
- There is **no env var or setting** to force worktree agents into tmux panes
### Why Opus says "Let me launch agents in worktrees"
Both systems are exposed through the **same subagent tool**. The model chooses which to use:
| Parameter | System | Visibility | Model preference |
|-----------|--------|------------|-----------------|
| `team_name` + `name` | Teammate | Visible tmux pane | Haiku, Sonnet |
| `isolation: "worktree"` | Worktree | Invisible in-process | Opus |
Opus prefers worktree agents because they provide **git-level isolation** — each agent works on its own branch and can't cause merge conflicts with other agents. The tradeoff is zero visibility.
### Workaround: Project Instructions
Since the model decides which system to use, you can influence its choice via `CLAUDE.md` project instructions:
```markdown
# Agent Configuration
When spawning subagents, always use the teammate system (team_name + name parameters)
instead of worktree isolation. This ensures agents are visible in tmux panes.
Do NOT use isolation: "worktree" — use teammates instead.
```
Place this in your project's `CLAUDE.md` or `~/.claude/CLAUDE.md` for global effect. This is a **best-effort** approach — the model may still choose worktree isolation for complex parallel tasks.
## Important: Interactive Mode Required
Agent teams spawn in separate tmux panes only when Claude Code is running **interactively** (the default when you type `claude` in a pane). When using `-p` (pipe/print mode), Claude intentionally runs agents in-process since there's no interactive terminal to split.
```powershell
# ✅ Interactive — agents spawn in tmux panes
claude
# ❌ Pipe mode — agents run in-process (by design)
claude -p "do something"
```
## Verifying the Setup
To confirm everything is configured correctly inside a psmux pane:
```powershell
# Check environment variables
Write-Host "TMUX: $env:TMUX"
Write-Host "AGENT_TEAMS: $env:CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"
Write-Host "TEAMMATE_MODE: $env:PSMUX_CLAUDE_TEAMMATE_MODE"
```
Expected output:
```
TMUX: /tmp/psmux-{pid}/default,{port},0
AGENT_TEAMS: 1
TEAMMATE_MODE: tmux
```
You can also verify the `claude` wrapper is active:
```powershell
Get-Command claude | Format-List
```
If the wrapper is active, this shows a `Function` (not an `Application`). The wrapper auto-injects `--teammate-mode tmux` when calling `claude.exe`.
## Troubleshooting
### Agents still running in-process
1. **Check you're in interactive mode** — not using `-p` or `--print`
2. **Verify env vars** — run the verification commands above
3. **Check debug log** — start Claude with `--debug-file $env:TEMP\claude_debug.log` and look for:
- `[TeammateModeSnapshot] Captured from CLI override: tmux` — teammate mode is set
- `[BackendRegistry] isInProcessEnabled: false` — tmux panes will be used
- `[BackendRegistry] isInProcessEnabled: true (non-interactive session)` — you're in pipe mode
### Opus using "worktree agents" instead of tmux panes
This is expected behavior. Opus prefers `isolation: "worktree"` over the teammate system. These are two completely different agent systems — see [Two Agent Systems](#two-agent-systems-in-claude-code) above.
**What you'll see:** Claude says "Let me launch 3 implementation agents in worktrees" — agents run invisibly, no panes appear.
**Workaround:** Add a `CLAUDE.md` instruction telling the model to prefer teammates over worktree isolation. This is best-effort — the model ultimately decides.
### Claude command not found
Make sure `claude.exe` is on your PATH. Install via:
```powershell
npm install -g @anthropic-ai/claude-code
```
### Wrapper not injecting `--teammate-mode`
The wrapper is only defined when `claude-code-fix-tty` is `on` (default). Check:
```powershell
tmux show-options -g claude-code-fix-tty
```
## Technical Details
For the curious — here's what happens under the hood when Claude Code spawns a teammate:
1. Claude calls `spawnTeammate` tool (available because `T8()` gate passes due to `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`)
2. `BackendRegistry.detectAndGetBackend()` checks `isInProcessEnabled`:
- If non-interactive → true → in-process (by design)
- If interactive → checks `teammateMode` → `"tmux"` → false → uses TmuxBackend
3. `TmuxBackend` runs `tmux split-window` via psmux's tmux compatibility
4. Sends `cd && claude.exe --agent-id --agent-name ...` via `tmux send-keys`
5. The teammate agent starts in its own pane with full terminal access
================================================
FILE: docs/compatibility.md
================================================
# tmux Compatibility
psmux is the most tmux-compatible terminal multiplexer on Windows.
## Overview
| Feature | Support |
|---------|---------|
| Commands | **83** tmux commands implemented |
| Format variables | **140+** variables with full modifier support |
| Config file | Reads `~/.tmux.conf` directly |
| Key bindings | `bind-key`/`unbind-key` with key tables, case-sensitive |
| Hooks | 15+ event hooks (`after-new-window`, etc.) with `set-hook`/`show-hooks` |
| Status bar | Full format engine with conditionals, loops, and multi-line support |
| Themes | 14 style options, 24-bit color, text attributes |
| Layouts | 5 layouts (even-h, even-v, main-h, main-v, tiled) |
| Copy mode | 53 vim keybindings, search, registers, rectangle select |
| Targets | `session:window.pane`, `session:window_name`, `%id`, `@id` syntax |
| `if-shell` / `run-shell` | ✅ Conditional config logic |
| Paste buffers | ✅ Full buffer management |
| Control mode | ✅ `-C` / `-CC` programmatic protocol |
| Popups and menus | ✅ `display-popup`, `display-menu` |
| Interactive choosers | ✅ `choose-tree`, `choose-buffer`, `choose-client` |
| Server namespaces | ✅ `-L` for isolated instances |
| Command chaining | ✅ Sequential `;` operator |
| Nesting prevention | ✅ Blocks psmux inside psmux |
| Session environment | ✅ `set-environment` / `show-environment` |
**Your existing `.tmux.conf` works.** psmux reads it automatically. Just install and go.
## Comparison
| | psmux | Windows Terminal tabs | WSL + tmux |
|---|:---:|:---:|:---:|
| Session persist (detach/reattach) | ✅ | ❌ | ⚠️ WSL only |
| Synchronized panes | ✅ | ❌ | ✅ |
| tmux keybindings | ✅ | ❌ | ✅ |
| Reads `.tmux.conf` | ✅ | ❌ | ✅ |
| tmux theme support | ✅ | ❌ | ✅ |
| Native Windows shells | ✅ | ✅ | ❌ |
| Full mouse support | ✅ | ✅ | ⚠️ Partial |
| Zero dependencies | ✅ | ✅ | ❌ (needs WSL) |
| Scriptable (83 commands) | ✅ | ❌ | ✅ |
| Claude Code agent teams | ✅ | ❌ | ✅ |
| CJK/IME text input | ✅ | ✅ | ✅ |
| Warm session pre-spawn | ✅ | N/A | ❌ |
## Supported Commands
For the full list of supported tmux commands and arguments, see [tmux_args_reference.md](tmux_args_reference.md).
## Recent Parity Improvements
This section covers tmux features that were recently brought to full parity.
### Case-sensitive Key Bindings
Key bindings now distinguish between lowercase and uppercase letters exactly like tmux. `bind-key T` binds to `Shift+T`, while `bind-key t` binds to lowercase `t`. This is critical for plugins like PPM (`Prefix+I` to install) and psmux-sensible (`Prefix+R` to reload).
### Ctrl+Space as Prefix
`set -g prefix C-Space` now works correctly. Previously, multi-character key names like `Space` were parsed as single character fallbacks.
### Wrapped Directional Pane Navigation
Directional pane navigation (`select-pane -U/-D/-L/-R`) now wraps at layout edges, matching tmux behavior. Navigating past the rightmost pane wraps to the leftmost, and so on. Wrap is also correctly suppressed while zoomed.
### Prefix Repeat Chaining
After pressing the prefix key, successive keypresses within the `repeat-time` window (default 500ms) each trigger the bound action without needing to re-enter the prefix. This matches tmux's repeat behavior for pane navigation and resize bindings.
### Switch Client
`switch-client` is fully functional with all standard flags (`-t`, `-n`, `-p`, `-l`). Use it to programmatically switch between sessions.
### Window Name Resolution in Targets
Target syntax now resolves window names, not just indices. `send-keys -t mysession:mywindow` correctly finds the window named "mywindow" in session "mysession".
### Manual Rename Flag
`new-window -n NAME` now sets the `manual_rename` flag, preventing `automatic-rename` from overwriting the explicitly specified window name with the foreground process name.
### List Commands from Within Session
Commands like `list-panes`, `list-windows`, `list-clients`, `list-commands`, and `show-hooks` now work when run from within a psmux session (via `Prefix + :`). Output is displayed in a temporary overlay.
### Source File from Within Session
`source-file` works from within a live session via `Prefix + :`. Previously, config changes only took effect after detaching and reattaching or killing the server.
### Display Panes Overlay
`display-panes` (and `Prefix + q`) now shows pane numbers briefly and auto-dismisses after `display-panes-time` (default 1s). Type a number during the overlay to switch to that pane.
### Hook Deduplication
`set-hook -g` now replaces existing hooks on reload instead of stacking duplicates. `set-hook -gu` correctly removes hooks.
### Command Chaining with Semicolons
Multiple commands can be chained with `;` on a single line, matching tmux behavior:
```tmux
bind-key M-s split-window -h \; select-pane -L
```
### Run Shell Output
`run-shell` now displays output in the status bar, matching tmux behavior. Background mode with `-b` runs fire and forget.
### Session Server Persistence
The psmux session server now survives SSH disconnects. On reconnect, sessions are intact and `psmux attach` reattaches normally.
### Bell and Alert Support
BEL characters (`\x07`) from programs are forwarded to your host terminal for audible beep. The `bell-action` option controls when bells are forwarded and when the status bar tab gets a bell flag.
### Pane Border Labels with Truncation
`pane-border-format` labels that exceed the pane width are now truncated with ellipsis instead of overflowing or clipping mid-character.
### Pane Title Management
`select-pane -T ""` correctly clears a pane title. The default pane title is the hostname, matching tmux convention. Programs can update the pane title via OSC 0/2 escape sequences (controlled by the `allow-set-title` option). See [pane-titles.md](pane-titles.md) for details on how this interacts with PowerShell and other shells.
### Multi-line Status Bar
`set -g status 2` enables a multi-line status bar with `status-format[0]` and `status-format[1]` fully rendering style directives like `#[fg=red]`, `#[align=left]`, and `#[fill=blue]`.
### Status Bar Style Directives
The following inline style directives are now rendered correctly in status-format lines:
- `#[list]` for the window list region
- `#[fill=colour]` for background fill
- `#[align=left|centre|right]` for text alignment
- `#[range=...]` for click regions
### Format Variable Expansion in Bindings
The `-F` flag on `bind-key` now properly expands format variables, enabling plugins like smart-splits.nvim to query pane dimensions.
### Set Environment
`set-environment` and `show-environment` are fully functional. Environment variables set with `set-environment -g` are inherited by all new panes at the process level (no shell commands echoed). The `new-session -e VAR=val` flag also sets session environment correctly.
### Unbind All Keys
`unbind-key -a` correctly removes all key bindings across all key tables. You can also target specific tables: `unbind-key -a -T prefix`, `unbind-key -a -T root`, `unbind-key -a -T copy-mode`.
### Client Prefix Format Variable
The `#{client_prefix}` format variable is correctly set when the prefix key is pressed. This enables status bar indicators like:
```tmux
set -g status-right "#{?client_prefix,#[bg=red] PREFIX ,}"
```
### Window Zoomed Flag
The `#{window_zoomed_flag}` format variable is correctly maintained during zoom/unzoom operations.
### Capture Pane
`capture-pane -p` correctly outputs pane content to stdout, enabling scripts and integrations (including Claude Code agent team coordination) to read pane state.
### Split Window Percentage
`split-window -p ` correctly creates splits at the specified percentage instead of defaulting to 50/50.
### Split Window Working Directory
`split-window -c "#{pane_current_path}"` correctly resolves the format variable and opens the new pane in the current pane's working directory.
### UTF-8 and CJK Support
Multi-byte UTF-8 characters (box-drawing, emoji, CJK text) render correctly in panes. Pasting CJK text no longer crashes the session. Japanese and Korean IME input is handled with minimal latency (the paste-detection heuristic was tuned to avoid misidentifying rapid IME bursts).
## Format Variables
psmux supports 140+ format variables with full modifier support, including:
- Session/window/pane variables (`#S`, `#W`, `#P`, `#{pane_current_path}`, etc.)
- Style and color modifiers
- Conditional expressions (`#{?condition,true,false}`)
- Comparison operators (`#{==:a,b}`, `#{!=:a,b}`, `#{<:a,b}`)
- Logical operators (`#{||:a,b}`, `#{&&:a,b}`)
- Regex substitution (`#{s/pat/rep/:var}`)
- String operations: basename (`#{b:}`), dirname (`#{d:}`), lowercase (`#{l:}`), shell quote (`#{q:}`)
- Truncation and padding (`#{=N:var}`, `#{pN:var}`)
- Loop iteration over windows (`#{W:fmt}`), panes (`#{P:fmt}`), and sessions (`#{S:fmt}`)
## Named Paste Buffers
psmux supports named paste buffers, matching tmux behavior:
```powershell
# Set a named buffer
psmux set-buffer -b mybuf "hello world"
# Show a named buffer
psmux show-buffer -b mybuf
# Delete a named buffer
psmux delete-buffer -b mybuf
# Paste from a named buffer
psmux paste-buffer -b mybuf
```
Named buffers are separate from the default (anonymous) buffer stack. They persist for the lifetime of the session and can be used for inter-pane data exchange in scripts and automation workflows.
## Developer Integration: Using psmux as a tmux Drop-in on Windows
psmux implements the same CLI protocol as tmux. Any tool, library, or script that drives tmux via subprocess commands will work on psmux with minimal or zero changes. This section covers what developers need to know when integrating.
### Same Protocol, Same Commands
psmux accepts the same command syntax as tmux:
```python
# This code works identically with both tmux (Linux/macOS) and psmux (Windows)
import subprocess
def run_mux(cmd):
binary = "tmux" # psmux installs a tmux.exe alias
result = subprocess.run([binary] + cmd, capture_output=True, text=True)
return result.stdout.strip()
# All of these work on both platforms
run_mux(["new-session", "-d", "-s", "work"])
run_mux(["list-sessions"])
run_mux(["send-keys", "-t", "work", "echo hello", "Enter"])
run_mux(["capture-pane", "-t", "work", "-p"])
run_mux(["list-windows", "-F", "#{window_id}:#{window_name}"])
run_mux(["kill-session", "-t", "work"])
```
Because psmux installs a `tmux.exe` alias, existing scripts that call `tmux` by name will find psmux on the PATH without any binary name changes.
### Stable IDs: `$N`, `@N`, `%N`
psmux uses the same stable ID scheme as tmux:
| Prefix | Entity | Example |
|--------|--------|---------|
| `$` | Session | `$0`, `$1` |
| `@` | Window | `@0`, `@1`, `@2` |
| `%` | Pane | `%0`, `%1`, `%2` |
These IDs are monotonically increasing and never reused during a server's lifetime. Use them for reliable targeting:
```powershell
# Target by session ID
psmux has-session -t "$0"
# Target by window ID
psmux select-window -t @2
# Target by pane ID
psmux send-keys -t %3 "echo hello" Enter
# Compound targets work too
psmux send-keys -t "$0:@2.%3" "echo hello" Enter
```
### Format Separator Encoding (Windows UTF-8)
Libraries that parse format output from `list-sessions -F`, `list-windows -F`, or `list-panes -F` should be aware of encoding on Windows.
psmux outputs UTF-8 encoded text. On Linux, tmux also outputs UTF-8, and most tools decode correctly because the system locale is UTF-8. On Windows, the default console code page is often cp1252 or cp437, not UTF-8.
If your library uses `subprocess.Popen(text=True)` in Python without specifying an encoding, Python will use the system default encoding (cp1252 on most Windows systems). This will garble any non-ASCII bytes in the output, including Unicode separator characters like U+241E that some libraries use internally.
**Fix**: Always specify `encoding="utf-8"` when reading psmux output:
```python
import subprocess
proc = subprocess.Popen(
["psmux", "list-sessions", "-F", "#{session_name}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8", # Required on Windows
errors="backslashreplace"
)
stdout, stderr = proc.communicate()
```
Alternatively, set the `PYTHONUTF8=1` environment variable to make Python use UTF-8 everywhere:
```powershell
$env:PYTHONUTF8 = "1"
python your_script.py
```
### libtmux Compatibility
[libtmux](https://github.com/tmux-python/libtmux) is the most popular Python library for programmatically controlling tmux. psmux is compatible with libtmux's API because it implements the same CLI commands and output formats.
#### Setup
```powershell
pip install libtmux
```
#### Usage
```python
import libtmux
# Connect to the running psmux server
server = libtmux.Server(socket_name="default")
# List sessions
for session in server.sessions:
print(f"Session: {session.name} (ID: {session.id})")
# Get windows and panes
session = server.sessions[0]
for window in session.windows:
print(f" Window: {window.name} (ID: {window.id})")
for pane in window.panes:
print(f" Pane: {pane.id}")
# Create a new window
new_win = session.new_window(window_name="build")
# Send keys to a pane
pane = new_win.panes[0]
pane.send_keys("echo hello from libtmux")
# Capture pane content
output = pane.capture_pane()
print(output)
# Kill the window
new_win.kill()
```
#### Windows Encoding Note for libtmux
libtmux internally uses a Unicode separator character (U+241E, `SYMBOL FOR RECORD SEPARATOR`) to split format query results. On Linux, this works transparently because tmux outputs UTF-8 and Python decodes with UTF-8.
On Windows, libtmux's `tmux_cmd` class uses `subprocess.Popen(text=True)` which defaults to cp1252 encoding. The 3-byte UTF-8 sequence for U+241E (0xE2 0x90 0x9E) gets decoded as three separate cp1252 characters, breaking the field parser.
**Workaround**: Patch libtmux's `common.py` to add `encoding="utf-8"` to the Popen call:
```python
# In libtmux/common.py, tmux_cmd.__init__
# Change:
# subprocess.Popen(cmd, stdout=PIPE, stderr=PIPE, text=True)
# To:
subprocess.Popen(cmd, stdout=PIPE, stderr=PIPE, text=True, encoding="utf-8", errors="backslashreplace")
```
Or set `PYTHONUTF8=1` globally before importing libtmux. This is an upstream libtmux issue (it should specify encoding explicitly for cross-platform support) and not specific to psmux.
### Cross-Platform Project Pattern
For projects that need terminal multiplexing on both Linux/macOS (tmux) and Windows (psmux):
```python
import platform
import subprocess
def get_mux_binary():
"""Get the terminal multiplexer binary for the current platform."""
# psmux installs a tmux.exe alias, so "tmux" works everywhere
return "tmux"
def mux_run(args, **kwargs):
"""Run a tmux/psmux command portably."""
binary = get_mux_binary()
kwargs.setdefault("capture_output", True)
kwargs.setdefault("text", True)
if platform.system() == "Windows":
kwargs.setdefault("encoding", "utf-8")
return subprocess.run([binary] + args, **kwargs)
def create_session(name, width=120, height=30):
"""Create a detached session."""
return mux_run(["new-session", "-d", "-s", name, "-x", str(width), "-y", str(height)])
def send_keys(target, keys):
"""Send keys to a target pane."""
return mux_run(["send-keys", "-t", target] + keys)
def capture_pane(target):
"""Capture pane content."""
result = mux_run(["capture-pane", "-t", target, "-p"])
return result.stdout
def list_sessions():
"""List all sessions."""
result = mux_run(["list-sessions", "-F", "#{session_name}"])
return result.stdout.strip().split("\n") if result.stdout.strip() else []
def kill_session(name):
"""Kill a session."""
return mux_run(["kill-session", "-t", name])
```
This pattern works identically on Linux (with tmux) and Windows (with psmux) because:
1. psmux installs a `tmux.exe` alias, so the binary name is the same
2. The CLI protocol (commands, flags, format strings) is identical
3. Stable IDs (`$N`, `@N`, `%N`) follow the same scheme
4. Control mode (`-C`/`-CC`) uses the same wire protocol
### What About GUI/IDE Integrations?
If you are building an IDE plugin, VS Code extension, or GUI application that manages terminal sessions:
1. **Use control mode** (`psmux -CC`) for persistent, event-driven integration. See [control-mode.md](control-mode.md).
2. **Use `dump-state`** (psmux extension) to get the full session state as JSON, including screen content.
3. **Query format variables** with `display-message -p "#{var}"` for lightweight state reads.
4. **Set environment variables** with `set-environment -g KEY val` to pass configuration to child processes.
5. **Use hooks** (`set-hook -g after-new-window ...`) to react to session events.
6. **Use `wait-for`** for cross-pane synchronization in multi-step automation.
For a complete developer integration guide with examples in Python, PowerShell, Node.js, and more, see [integration.md](integration.md).
================================================
FILE: docs/configuration.md
================================================
# Configuration
psmux reads its config on startup from the **first file found** (in order):
1. `~/.psmux.conf`
2. `~/.psmuxrc`
3. `~/.tmux.conf`
4. `~/.config/psmux/psmux.conf`
Config syntax is **tmux-compatible**. Most `.tmux.conf` lines work as-is.
You can also specify a custom config file path with the `-f` flag:
```powershell
# Use a specific config file instead of default search
psmux -f ~/.config/psmux/custom.conf
# Use an empty config (no settings loaded)
psmux -f NUL
```
This sets the `PSMUX_CONFIG_FILE` environment variable internally, which the server checks before searching the default locations.
## Basic Config Example
Create `~/.psmux.conf`:
```tmux
# Change prefix key to Ctrl+a
set -g prefix C-a
# Enable mouse
set -g mouse on
# Window numbering base (default is 1)
set -g base-index 1
# Customize status bar
set -g status-left "[#S] "
set -g status-right "%H:%M %d-%b-%y"
set -g status-style "bg=green,fg=black"
# Cursor style: block, underline, or bar
set -g cursor-style bar
set -g cursor-blink on
# Scrollback history
set -g history-limit 5000
# Prediction dimming (disable for apps like Neovim)
set -g prediction-dimming off
# Key bindings
bind-key -T prefix h split-window -h
bind-key -T prefix v split-window -v
```
## Choosing a Shell
psmux launches **PowerShell 7 (pwsh)** by default. You can change this:
```tmux
# Use cmd.exe
set -g default-shell cmd
# Use PowerShell 5 (Windows built-in)
set -g default-shell powershell
# Use PowerShell 7 (explicit path)
set -g default-shell "C:/Program Files/PowerShell/7/pwsh.exe"
# Use Git Bash
set -g default-shell "C:/Program Files/Git/bin/bash.exe"
# Use Nushell
set -g default-shell nu
# Use Windows Subsystem for Linux (via wsl.exe)
set -g default-shell wsl
```
You can also launch a window with a specific command without changing the default:
```powershell
psmux new-window -- cmd /K echo hello
psmux new-session -s py -- python
psmux split-window -- "C:/Program Files/Git/bin/bash.exe"
```
## All Set Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `prefix` | Key | `C-b` | Prefix key |
| `prefix2` | Key | `none` | Secondary prefix key (optional) |
| `base-index` | Int | `0` | First window number |
| `pane-base-index` | Int | `0` | First pane number |
| `escape-time` | Int | `500` | Escape delay (ms) |
| `repeat-time` | Int | `500` | Repeat key timeout (ms) |
| `history-limit` | Int | `2000` | Scrollback lines per pane |
| `display-time` | Int | `750` | Message display time (ms) |
| `display-panes-time` | Int | `1000` | Pane overlay time (ms) |
| `status-interval` | Int | `15` | Status refresh (seconds) |
| `mouse` | Bool | `on` | Mouse support |
| `mouse-selection` | Bool | `on` | psmux's client-side drag selection. Set `off` to let in-pane TUI apps (opencode, nvim, etc.) handle their own mouse selection without psmux drawing on top |
| `scroll-enter-copy-mode` | Bool | `on` | Enter copy mode on mouse scroll (set `off` to disable) |
| `pwsh-mouse-selection` | Bool | `off` | Windows 11 PowerShell-style word/line selection (double/triple-click) |
| `paste-detection` | Bool | `on` | Detect Ctrl+V paste from console host and send as bracketed paste (set `off` to let Ctrl+V reach child apps like neovim) |
| `choose-tree-preview` | Bool | `off` | Open `choose-session` / `choose-tree` pickers with the live preview pane already visible (saves pressing `p`). See [preview.md](preview.md) |
| `status` | Bool/Int | `on` | Show status bar (number = line count) |
| `status-position` | Str | `bottom` | `top` or `bottom` |
| `status-justify` | Str | `left` | `left`, `centre`, `right`, `absolute-centre` |
| `status-left-length` | Int | `10` | Max width of status-left |
| `status-right-length` | Int | `40` | Max width of status-right |
| `focus-events` | Bool | `off` | Pass focus events to apps |
| `mode-keys` | Str | `emacs` | `vi` or `emacs` |
| `renumber-windows` | Bool | `off` | Auto-renumber windows on close |
| `automatic-rename` | Bool | `on` | Rename windows from foreground process |
| `monitor-activity` | Bool | `off` | Flag windows with new output |
| `monitor-silence` | Int | `0` | Seconds before silence flag (0=off) |
| `visual-activity` | Bool | `off` | Visual indicator for activity |
| `synchronize-panes` | Bool | `off` | Send input to all panes |
| `remain-on-exit` | Bool | `off` | Keep panes after process exits |
| `aggressive-resize` | Bool | `off` | Resize to smallest client |
| `window-size` | Str | `latest` | `largest`, `smallest`, `manual`, `latest` |
| `destroy-unattached` | Bool | `off` | Exit server when no clients attached |
| `exit-empty` | Bool | `on` | Exit server when all windows closed |
| `set-titles` | Bool | `off` | Update terminal title |
| `set-titles-string` | Str | | Terminal title format |
| `default-shell` | Str | `pwsh` | Shell to launch |
| `default-command` | Str | | Alias for default-shell |
| `word-separators` | Str | `" -_@"` | Copy-mode word delimiters |
| `activity-action` | Str | `other` | Action on window activity: `any`, `none`, `current`, `other` |
| `silence-action` | Str | `other` | Action on window silence: `any`, `none`, `current`, `other` |
| `bell-action` | Str | `any` | Bell action: controls audible bell forwarding and status bar flag (`any`, `none`, `current`, `other`) |
| `visual-bell` | Bool | `off` | Visual bell indicator |
| `allow-passthrough` | Str | `off` | Allow terminal passthrough sequences (`on`/`off`/`all`) |
| `allow-rename` | Bool | `on` | Allow programs to set window title via escape sequences |
| `allow-set-title` | Bool | `off` | Allow programs to set pane title via OSC 0/2 escape sequences (see [pane-titles.md](pane-titles.md)) |
| `allow-predictions` | Bool | `off` | Preserve PSReadLine prediction settings (see below) |
| `default-terminal` | Str | | Terminal type string (sets `TERM` env var in panes) |
| `update-environment` | Str | *(tmux defaults)* | Space-separated list of env vars to refresh on client attach |
| `warm` | Bool | `on` | Pre-spawn shells for instant window/pane creation (see [warm-sessions.md](warm-sessions.md)) |
| `copy-command` | Str | | Shell command for clipboard pipe |
| `set-clipboard` | Str | `on` | Clipboard interaction (`on`/`off`/`external`) |
| `main-pane-width` | Int | `0` | Main pane width in main-vertical layout |
| `main-pane-height` | Int | `0` | Main pane height in main-horizontal layout |
### Style Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `status-left` | Str | `[#S] ` | Left status bar content |
| `status-right` | Str | | Right status bar content |
| `status-style` | Str | `bg=green,fg=black` | Status bar style |
| `status-left-style` | Str | | Left status style |
| `status-right-style` | Str | | Right status style |
| `message-style` | Str | `bg=yellow,fg=black` | Message style |
| `message-command-style` | Str | `bg=black,fg=yellow` | Command prompt style |
| `mode-style` | Str | `bg=yellow,fg=black` | Copy-mode highlight |
| `pane-border-style` | Str | | Inactive border style |
| `pane-active-border-style` | Str | `fg=green` | Active border style |
| `pane-border-format` | Str | | Pane border format string (e.g. `#{pane_index}: #{pane_title}`) |
| `pane-border-status` | Str | | Pane border status position (`top`/`bottom`/`off`) |
| `window-status-format` | Str | `#I:#W#F` | Inactive tab format |
| `window-status-current-format` | Str | `#I:#W#F` | Active tab format |
| `window-status-separator` | Str | `" "` | Tab separator |
| `window-status-style` | Str | | Inactive tab style |
| `window-status-current-style` | Str | | Active tab style |
| `window-status-activity-style` | Str | `reverse` | Activity tab style |
| `window-status-bell-style` | Str | `reverse` | Bell tab style |
| `window-status-last-style` | Str | | Last-active tab style |
### Multi-line Status Bar (`status-format[]`)
psmux supports a multi-line status bar using the `status-format[]` array. Set the `status` option to a number to control how many lines the status bar displays:
```tmux
# Enable a 2-line status bar
set -g status 2
# Configure each line (0-indexed)
set -g status-format[0] "#[align=left]#S #[align=right]%H:%M"
set -g status-format[1] "#[align=left]#{W:#I:#W }"
```
The first line (`status-format[0]`) replaces the default status bar content. Additional lines stack below (or above, depending on `status-position`).
### Pane Border Labels
Show pane information on the border between panes:
```tmux
# Enable pane border labels at the top of each pane
set -g pane-border-status top
# Customize what the label shows
set -g pane-border-format " #{pane_index}: #{pane_title} [#{pane_current_command}] "
# Disable pane border labels
set -g pane-border-status off
```
Use `select-pane -T "title"` to set a pane title that appears in the border label. Clear a title with `select-pane -T ""`. The default pane title is the hostname, matching tmux convention.
> **Note:** PowerShell 7 automatically sets the pane title to the current working directory on every prompt via OSC escape sequences. If you see a file path in your pane border labels instead of the hostname, see [pane-titles.md](pane-titles.md) for details and options to control this.
### Bell
When a program inside a pane emits BEL (`\x07`), psmux forwards the bell character to your host terminal so you hear the audible beep. The `bell-action` option controls when this happens and when the status bar tab gets a bell flag (`!`):
```tmux
# Forward bell from any window (default)
set -g bell-action any
# Forward bell only from the active window
set -g bell-action current
# Forward bell only from non-active windows
set -g bell-action other
# Mute bell completely (no sound, no status bar flag)
set -g bell-action none
```
The `window-status-bell-style` option controls how the tab looks when flagged:
```tmux
set -g window-status-bell-style "fg=red,bold"
```
PowerShell example to test:
```powershell
# These should all produce an audible beep inside psmux:
Write-Host "`a"
[Console]::Beep()
[char]7
```
### Mouse Configuration
Mouse support is enabled by default. You can customize how the mouse interacts with psmux:
```tmux
# Disable mouse entirely (no click, scroll, or drag)
set -g mouse off
# Disable entering copy mode on mouse scroll
set -g scroll-enter-copy-mode off
# Enable Windows 11 PowerShell-style word/line selection
# Double-click selects a word, triple-click selects a line
set -g pwsh-mouse-selection on
```
When `scroll-enter-copy-mode` is `off`, scrolling in a pane does not enter copy mode and instead passes scroll events directly to the running application.
#### Disabling psmux's drag selection (`mouse-selection`)
Some TUI applications render their own internal layouts (multiple columns, sidebars, panels) inside a single psmux pane. Examples include `opencode`, `lazygit`, `nvim` with split windows, and similar dashboards.
psmux's own client-side drag selection does not know about those internal layouts, so a left-click drag inside such an app draws a selection rectangle that crosses the app's internal columns instead of respecting them.
If you would rather have the application handle mouse selection itself, disable psmux's drag selection:
```tmux
# Let the app inside the pane handle its own mouse selection.
# psmux will no longer render its drag-selection rectangle.
set -g mouse-selection off
```
What still works when `mouse-selection` is `off`:
- Click on a pane to focus it
- Click on a window tab in the status bar to switch to it
- Mouse wheel scrolling and scroll-into-copy-mode
- Pane border drag-to-resize
- Mouse events being forwarded to applications that request mouse tracking (DECSET 1000/1002/1003), so `opencode`, `htop`, `nvim`, `claude`, etc. continue to receive their clicks and drags
What changes when `mouse-selection` is `off`:
- psmux no longer draws its own selection rectangle on left-click drag
- Right-click clipboard copy via psmux's selection is no longer triggered (selection never starts)
- The Windows 11 style word/line multi-click (`pwsh-mouse-selection`) is suppressed too while `mouse-selection off` is in effect
To restore the default behaviour:
```tmux
set -g mouse-selection on
```
You can also toggle this at runtime without restarting:
```
psmux set-option -g mouse-selection off
psmux set-option -g mouse-selection on
```
This option is independent of `mouse` (which controls whether mouse events are received at all) and `pwsh-mouse-selection` (which only affects the style of the drag selection when it is active).
### Paste Detection (Ctrl+V Passthrough)
On Windows, the console host intercepts Ctrl+V, reads the clipboard, and injects the content as character events. psmux detects this pattern and reassembles it into a single bracketed paste for child applications. This is the `paste-detection` option and it is enabled by default.
If you use TUI applications like **neovim** or **vim** where Ctrl+V has a different meaning (visual block mode), the paste detection will intercept the keypress before it reaches the application. To let Ctrl+V pass through to the child app:
```tmux
# Disable paste detection so Ctrl+V reaches child apps
set -g paste-detection off
```
With paste detection off, you can still paste using:
* **Ctrl+Shift+V** (Windows Terminal default paste shortcut)
* **Right click** (paste in most terminals)
* **Prefix + ]** (psmux paste from buffer)
* **`psmux send-keys C-v`** from another terminal
> **Note:** `unbind-key -n C-v` alone is not sufficient to stop Ctrl+V interception because the paste detection operates outside the key binding system. You must use `set -g paste-detection off`.
### Live Preview in Choosers
`choose-session` (prefix + s) and `choose-tree` (prefix + w) include a live preview pane that mirrors the selected session or window in real time. By default it is hidden and you press `p` to toggle it. To make it visible by default:
```tmux
# Open all choosers with the preview pane already visible
set -g choose-tree-preview on
```
You can still press `p` inside the chooser to hide it for the current session. The setting is read once when the chooser opens, so changes to the option take effect immediately on the next open. See [preview.md](preview.md) for the full feature documentation.
### Command Chaining
psmux supports tmux-style command chaining with the `;` operator. Multiple commands on a single line are executed sequentially:
```tmux
# Split and move focus in one binding
bind-key M-s split-window -h \; select-pane -L
# Create a development layout
bind-key D split-window -v -p 30 \; split-window -h \; select-pane -t 0
```
In config files, escape the semicolon with `\;` so it is not treated as a comment delimiter.
### Case-Sensitive Key Bindings
psmux distinguishes between lowercase and uppercase letters in key bindings, matching tmux behavior:
```tmux
# These are two different bindings:
bind-key t clock-mode # Prefix + t (lowercase)
bind-key T choose-tree # Prefix + Shift+T (uppercase)
# Uppercase bindings for plugin managers
bind-key I run-shell '~/.psmux/plugins/ppm/scripts/install_plugins.ps1'
bind-key U run-shell '~/.psmux/plugins/ppm/scripts/update_plugins.ps1'
```
### Ctrl+Space as Prefix
Multi-character key names like `Space`, `Enter`, `Tab`, and `Escape` are fully supported in prefix configuration:
```tmux
set -g prefix C-Space
unbind-key C-b
bind-key C-Space send-prefix
```
### psmux Extensions (Windows-specific)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `prediction-dimming` | Bool | `off` | Dim predictive/speculative text |
| `paste-detection` | Bool | `on` | Detect Ctrl+V paste from console host (set `off` for neovim/vim Ctrl+V) |
| `cursor-style` | Str | | Cursor shape: `block`, `underline`, or `bar` |
| `cursor-blink` | Bool | `off` | Cursor blinking |
| `env-shim` | Bool | `on` | Inject Unix-compatible `env` function in PowerShell panes |
| `claude-code-fix-tty` | Bool | `on` | Patch Node.js process.stdout.isTTY for Claude Code |
| `claude-code-force-interactive` | Bool | `on` | Set CLAUDE_CODE_FORCE_INTERACTIVE=1 in panes |
Style format: `"fg=colour,bg=colour,bold,dim,underscore,italics,reverse,strikethrough"`
Colours: `default`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `colour0`–`colour255`, `#RRGGBB`
## Environment Variables
```powershell
# Default session name used when not explicitly provided
$env:PSMUX_DEFAULT_SESSION = "work"
# Enable prediction dimming (off by default; dims predictive/speculative text)
$env:PSMUX_DIM_PREDICTIONS = "1"
# Disable warm pane pre-spawning (same as set -g warm off)
$env:PSMUX_NO_WARM = "1"
# Override the config file path (same effect as -f flag)
$env:PSMUX_CONFIG_FILE = "C:\Users\me\.psmux-alt.conf"
# These are set INSIDE psmux panes (tmux-compatible):
# TMUX - socket path and server info
# TMUX_PANE - current pane ID (%0, %1, etc.)
```
## Managing Environment Variables
Use `set-environment` to set env vars that are inherited by newly created panes:
```powershell
# Set a global env var (inherited by all new panes)
psmux set-environment -g EDITOR vim
# Set a session-scoped env var
psmux set-environment MY_VAR value
# Unset an env var
psmux set-environment -gu MY_VAR
# Show all environment variables
psmux show-environment
psmux show-environment -g
```
Environment variables set this way are injected at the process level when new panes spawn, so they are completely invisible (no commands echoed in the shell).
## PSReadLine Predictions (Intellisense / Autocompletion)
By default, psmux disables PSReadLine inline predictions (the grayed-out autocompletion/intellisense suggestions that appear as you type) to avoid additional unexpected bugs caused by the interaction between predictions and ConPTY. This means `PredictionSource` defaults to `None` inside psmux, even if your profile sets it to `HistoryAndPlugin` ([#150](https://github.com/psmux/psmux/issues/150)).
If enough people test predictions and the community supports enabling them by default, this will be changed in a future release.
To preserve your prediction/autocompletion settings, enable `allow-predictions`:
```tmux
set -g allow-predictions on
```
With this enabled:
- If your profile sets `PredictionSource`, psmux respects your choice
- If your profile does not set it, psmux restores the system default (typically `HistoryAndPlugin`)
## Prediction Dimming
Prediction dimming is off by default. If you want psmux to dim predictive/speculative text (e.g. shell autosuggestions), you can enable it in `~/.psmux.conf`:
```tmux
set -g prediction-dimming on
```
You can also enable it for the current shell only:
```powershell
$env:PSMUX_DIM_PREDICTIONS = "1"
psmux
```
To make it persistent for new shells:
```powershell
setx PSMUX_DIM_PREDICTIONS 1
```
## Reloading Configuration at Runtime
You can reload your config file without restarting psmux. From the command prompt (`Prefix + :`), run:
```tmux
source-file ~/.psmux.conf
```
Or from outside psmux:
```powershell
psmux source-file ~/.psmux.conf
```
This re-executes every line in the config file, applying any changes to options, key bindings, hooks, and styles immediately.
## Window and Pane Numbering
By default, windows and panes are numbered starting from 0. You can change the starting index for both:
```tmux
# Start window numbering at 1
set -g base-index 1
# Start pane numbering at 1
set -g pane-base-index 1
```
The `pane-base-index` setting affects:
- **Display Panes overlay** (`Prefix + q`): The numbers shown on each pane start from your configured base index
- **Pane targets**: When referencing panes by number (e.g. `select-pane -t 1`), numbering follows your base index
- **Format variables**: `#{pane_index}` reflects the base index setting
- **Status bar and border labels**: Pane numbers in format strings use the configured base
A common setup for both windows and panes to start at 1:
```tmux
set -g base-index 1
set -g pane-base-index 1
```
## Display Panes Overlay
Press `Prefix + q` to show numbered overlays on each pane. While the overlay is visible, press any displayed number key to jump to that pane. The overlay auto-dismisses after `display-panes-time` milliseconds (default: 1000ms).
```tmux
# Show pane numbers for 3 seconds
set -g display-panes-time 3000
```
The numbers shown respect your `pane-base-index` setting. For example, with `pane-base-index 1`, three panes show as 1, 2, 3 instead of 0, 1, 2.
You can also trigger this overlay from the command line:
```powershell
psmux display-panes
```
## Split Window Options
When splitting panes, you can control the size and starting directory of the new pane:
```tmux
# Split vertically, new pane takes 30% of the space
split-window -v -p 30
# Split horizontally, new pane takes 70% of the space
split-window -h -p 70
# Split and start in a specific directory
split-window -v -c "C:\Projects\myapp"
# Split and start in the current pane's directory
split-window -h -c "#{pane_current_path}"
# Split and run a specific command
split-window -v -- python
```
These flags also work when creating new windows:
```tmux
# New window with a specific name
new-window -n "logs"
# New window in a specific directory
new-window -c "C:\Projects"
# New window running a specific command with a name
new-window -n "build" -- cargo build --watch
```
When you set a window name with `-n`, the `automatic-rename` flag is turned off for that window so psmux does not overwrite your chosen name with the foreground process name. To re-enable automatic renaming for that window:
```tmux
set-option -w automatic-rename on
```
## Detach and Exit Policies
Control what happens when clients disconnect or all windows close:
```tmux
# Exit the server when no clients are attached (default: off)
set -g destroy-unattached on
# Exit the server when the last window/session closes (default: on)
set -g exit-empty on
```
With `destroy-unattached on`, the server process terminates as soon as the last client detaches. This is useful for single-use sessions.
With `exit-empty off`, the server stays alive even after all sessions are closed, allowing new sessions to be created without restarting.
## Dead Panes and Respawn
When a process inside a pane exits, the pane normally closes. To keep the pane visible after its process exits:
```tmux
set -g remain-on-exit on
```
A pane with a dead process shows its last output and can be respawned:
```powershell
# Restart the default shell in the pane
psmux respawn-pane
# Kill any remaining process and restart
psmux respawn-pane -k
# Respawn in a different directory
psmux respawn-pane -c "C:\Projects"
# Respawn with a specific command
psmux respawn-pane -- python app.py
```
This is useful for monitoring: if a long-running process crashes, you can see its final output and restart it without losing the pane layout.
## Session Environment Variables
You can set environment variables at the session or global level that get inherited by all new panes:
```powershell
# Set a global env var (all new panes in all sessions inherit this)
psmux set-environment -g EDITOR vim
# Set a session-scoped env var
psmux set-environment MY_VAR value
# Unset a global env var
psmux set-environment -gu MY_VAR
# View all environment variables
psmux show-environment
psmux show-environment -g
```
You can also pass environment variables when creating a new session:
```powershell
# Create a session with custom environment
psmux new-session -s work -e "PROJECT=myapp" -e "ENV=production"
```
## Status Bar Time Updates
The status bar supports time format variables that update in real time:
```tmux
# Show current time in the status bar (updates every second)
set -g status-right "%H:%M:%S %d-%b-%y"
# Common time format variables:
# %H Hour (24-hour, 00-23)
# %I Hour (12-hour, 01-12)
# %M Minute (00-59)
# %S Second (00-59)
# %p AM/PM
# %r Full time in 12-hour format (e.g. 02:30:45 PM)
# %R Hour:Minute in 24-hour format (e.g. 14:30)
# %d Day of month (01-31)
# %b Abbreviated month name (Jan, Feb, ...)
# %Y Full year (2025)
# %a Abbreviated weekday (Mon, Tue, ...)
```
Time variables refresh based on the `status-interval` option (default: 15 seconds). For second-level precision, reduce the interval:
```tmux
# Update status bar every second (for live clock)
set -g status-interval 1
```
## PSReadLine ListView
psmux supports PSReadLine's ListView prediction style, which shows a dropdown list of suggestions:
```powershell
# In your PowerShell profile ($PROFILE)
Set-PSReadLineOption -PredictionSource HistoryAndPlugin
Set-PSReadLineOption -PredictionViewStyle ListView
```
For this to work inside psmux, enable `allow-predictions` in your psmux config:
```tmux
set -g allow-predictions on
```
Without `allow-predictions on`, psmux resets PSReadLine's prediction settings during initialization, which disables ListView mode.
================================================
FILE: docs/control-mode.md
================================================
# Control Mode
Control mode lets external programs drive psmux programmatically over a structured text protocol. Instead of rendering a TUI, psmux sends machine-readable notifications and accepts commands over stdin/stdout, making it the foundation for building plugins, IDE integrations, custom dashboards, session monitors, and any tooling that needs to interact with terminal sessions.
This is the same protocol that tmux uses for its control mode (`tmux -C` / `tmux -CC`), so existing knowledge and many client libraries transfer directly to psmux.
## Quick Start
```powershell
# 1. Create a detached session
psmux new-session -d -s work -x 120 -y 30
# 2. Attach in control mode (no-echo)
psmux -CC
```
psmux connects to the running session and enters a command/response loop. You type commands on stdin, and psmux responds on stdout with structured output.
```
list-windows
%begin 1700000000 1 1
0: pwsh* (1 panes) [120x30]
%end 1700000000 1 1
```
To exit, close stdin (Ctrl+D / EOF) or send `kill-server`.
## Flags
| Flag | Mode | Behavior |
|------|------|----------|
| `-C` | Echo | Commands you send are echoed back to stdout before the response. Useful for debugging and interactive testing. |
| `-CC` | No-echo | Commands are not echoed. This is the mode you want for programmatic use. In this mode, `%exit` is followed by an ST sequence (`ESC \`). |
## Session Targeting
By default, control mode connects to the session stored in `PSMUX_SESSION_NAME`. You can set it before launching:
```powershell
$env:PSMUX_SESSION_NAME = "my-session"
psmux -CC
```
## Wire Protocol
### Command/Response Framing
Every command you send gets a response wrapped in `%begin` / `%end` (or `%error`) markers:
```
%begin
%end |