diff --git a/app/assets/bundled/bootstrap/pwsh.ps1 b/app/assets/bundled/bootstrap/pwsh.ps1 index 599b200b7ef..dbf58032c65 100644 --- a/app/assets/bundled/bootstrap/pwsh.ps1 +++ b/app/assets/bundled/bootstrap/pwsh.ps1 @@ -1031,6 +1031,31 @@ $null = New-Module -Name Warp-Module -ScriptBlock { function Warp-Finish-Bootstrap { param([decimal]$rcStartTime, [decimal]$rcEndTime) + + # Warp communicates with the shell over a ConPTY channel, which is always UTF-8, but the + # console session decodes the bytes we write into it using the console input code page -- + # which Windows PowerShell 5.1 defaults to the system's legacy OEM/ANSI code page (e.g. 857 + # on Turkish Windows), not UTF-8. Non-ASCII characters in a command written to the PTY (e.g. + # a path with Turkish characters accepted from autocomplete) then arrive mangled and the + # path no longer resolves. Windows PowerShell 5.1's $OutputEncoding also defaults to strict + # ASCII, turning any non-ASCII character into a literal '?' when piping to a native program. + # Pin all three to BOM-less UTF-8 so non-ASCII text round-trips; a BOM-less encoding instance + # additionally avoids 5.1 prepending a UTF-8 BOM to native stdin once the input code page is + # 65001. This runs after the user's profiles, which could otherwise reset the encodings. + # PowerShell 7 and non-Windows hosts already default to UTF-8, and $IsWindows doesn't exist + # on 5.1, hence the version check. Some hosts disallow changing the encodings, hence the + # catch. + if (($PSVersionTable.PSVersion.Major -le 5) -or $IsWindows) { + try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $global:OutputEncoding = $utf8NoBom + [Console]::InputEncoding = $utf8NoBom + [Console]::OutputEncoding = $utf8NoBom + } catch { + Write-Verbose "Unable to pin console encodings to UTF-8: $($_.Exception.Message)" + } + } + Warp-Configure-PSReadLine # This is the closest we can get in PowerShell to a proper preexec hook. We wrap the diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 0dfca1a2f49..4c160726aa7 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -3862,6 +3862,43 @@ fn combined_completions_show_file_paths_after_empty_native_results() { }); } +#[test] +fn completion_acceptance_preserves_non_ascii_characters() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let terminal = + add_window_with_bootstrapped_terminal(&mut app, None, None /* session_info */).await; + let input = terminal.read(&app, |terminal, _| terminal.input().clone()); + + // "café" sits left of the completed token, so the byte span (9..13) starts after multi-byte + // characters; "Mäsärästï" is non-ASCII inside the token. "cd café Mäs" is 13 bytes long. + input.update(&mut app, |input, ctx| { + input.clear_buffer_and_reset_undo_stack(ctx); + input.user_insert("cd café Mäs", ctx); + }); + input.update(&mut app, |input, ctx| { + input.input_tab(ctx); + input.handle_completion_suggestions_results( + build_suggestion_results( + vec![argument_suggestion("Mäsärästï")], + (9, 13), + MatchStrategy::CaseInsensitive, + ), + CompletionsTrigger::Keybinding, + editor_model_snapshot(input, ctx), + ctx, + ); + }); + input.read(&app, |input, ctx| { + assert_eq!( + input.buffer_text(ctx), + "cd café Mäsärästï ", + "accepting a non-ASCII completion must not mangle any character" + ); + }); + }); +} + #[test] fn combined_completions_preserve_nonempty_native_results() { let _native_completions_flag = FeatureFlag::NativeShellCompletions.override_enabled(true); @@ -4060,6 +4097,97 @@ fn native_shell_replacement_span_falls_back_to_the_whitespace_token_when_none_is assert_eq!(results.replacement_span, Span::new(3, 8)); } +#[test] +fn native_shell_replacement_span_is_floored_to_char_boundaries() { + // "cd café Mäs" contains multi-byte characters: 'é' occupies bytes 6..8 and 'ä' bytes 10..12, + // so an end of 11 lands mid-character and would panic if used to slice the buffer. + let buffer_text = "cd café Mäs"; + + let results = native_shell_suggestion_results( + Vec::new(), + Some(Span::new(9, 11)), + buffer_text, + buffer_text.len(), + ); + + assert_eq!(results.replacement_span, Span::new(9, 10)); +} + +#[test] +fn accepting_a_native_completion_preserves_non_ascii_characters() { + let _native_completions_flag = FeatureFlag::NativeShellCompletions.override_enabled(true); + + App::test((), |mut app| async move { + initialize_app(&mut app); + app.update(|ctx| { + InputSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .warp_completions_enabled + .set_value(true, ctx) + .expect("Warp completions setting must update"); + settings + .native_shell_completions_enabled + .set_value(true, ctx) + .expect("native completions setting must update"); + }); + }); + + let session_info = SessionInfo::new_for_test(); + let session_id = session_info.session_id; + let terminal = + add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await; + simulate_directory_for_completion(session_id, &terminal, &mut app, "/usr/bin"); + + // Mimic a shell reply for "warptool Mäs": the candidates are non-ASCII paths and the span + // is the byte range (9..13) of the token the shell reports, as the pwsh bootstrap emits. + // Two candidates sharing only the short prefix "Mäs" keeps the menu open (rather than + // auto-inserting a single match or the common prefix), so acceptance can be exercised. + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event: &TerminalViewEvent, _| { + if let TerminalViewEvent::RunNativeShellCompletions { results_tx, .. } = event { + results_tx + .try_send(( + vec![ + ShellCompletion::new("Mäsärästü".to_string()), + ShellCompletion::new("Mäsótïlo".to_string()), + ], + Some(Span::new(9, 13)), + )) + .expect("native completion response receiver must remain open"); + } + }); + }); + let input = terminal.read(&app, |terminal, _| terminal.input().clone()); + + input.update(&mut app, |input, ctx| { + input.clear_buffer_and_reset_undo_stack(ctx); + input.user_insert("warptool Mäs", ctx); + input.input_tab(ctx); + }); + + assert_eventually!( + 600 => input.read(&app, |input, _| { + input.input_suggestions.read(&app, |suggestions, _| { + let items = suggestions.items().iter().map(|item| item.text()).collect_vec(); + items.contains(&"Mäsärästü") && items.contains(&"Mäsótïlo") + }) + }), + "gave up waiting for the non-ASCII native suggestions" + ); + + input.update(&mut app, |input, ctx| { + input.confirm_suggestion("Mäsärästü", ctx); + }); + input.read(&app, |input, ctx| { + assert_eq!( + input.buffer_text(ctx), + "warptool Mäsärästü ", + "accepting a non-ASCII completion must insert it verbatim" + ); + }); + }); +} + #[test] fn test_tab_completion_with_multibyte_chars() { App::test((), |mut app| async move {