From 0d71c0bcb12271da5fb6522b8ce27a4593051aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 10:48:22 -0400 Subject: [PATCH 1/5] Fix Debug build and launch on hosts with a newer Zig Resolve-ZigPath took any zig on PATH, so a newer system Zig (e.g. 0.17.0) was used for Ghostty, which only accepts the pinned version. Only accept a PATH zig whose version matches the pin; otherwise fall back to the pinned download under artifacts/tools. Debug builds also crashed at startup because developer tools were attached twice: WithDeveloperTools() in Program.BuildAvaloniaApp and AttachDeveloperTools() in App.Initialize. Drop the duplicate in App. Co-Authored-By: Claude Opus 5.5 --- native/Zig.ps1 | 8 +++++++- src/Devolutions.Terminal/App.axaml.cs | 3 --- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/native/Zig.ps1 b/native/Zig.ps1 index 417aa2a..745d6df 100644 --- a/native/Zig.ps1 +++ b/native/Zig.ps1 @@ -125,9 +125,15 @@ function Resolve-ZigPath { return $ZigPath } + # Only use a zig on PATH if it matches the pinned version; Ghostty rejects other versions. $command = Get-Command zig -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $command) { - return $command.Source + $pathVersion = (& $command.Source version 2>$null | Out-String).Trim() + if ($pathVersion -eq $Version) { + return $command.Source + } + + Write-Host "Ignoring zig $pathVersion at '$($command.Source)'; Zig $Version is required." } $zigName = if (Test-HostWindows) { "zig.exe" } else { "zig" } diff --git a/src/Devolutions.Terminal/App.axaml.cs b/src/Devolutions.Terminal/App.axaml.cs index 5952104..49f8227 100644 --- a/src/Devolutions.Terminal/App.axaml.cs +++ b/src/Devolutions.Terminal/App.axaml.cs @@ -23,9 +23,6 @@ public partial class TerminalApp : Application public override void Initialize() { AvaloniaXamlLoader.Load(this); -#if DEBUG - this.AttachDeveloperTools(); -#endif } public override void OnFrameworkInitializationCompleted() From 4ff0428f8ddb384b86306bab45bfceb0d9fc814f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 10:57:50 -0400 Subject: [PATCH 2/5] Fix Iseberg completion crash on empty input On empty or whitespace-only input, CommandCompletion.CompleteInput returns ReplacementIndex/ReplacementLength of -1. ManagedPowerShellSession passed that through, so Ctrl+Space in an empty script editor failed with "start ('-1') must be a non-negative value" when building the editor's replacement span. Map a negative replacement range to an empty span at the cursor in one helper shared by the normal, nested-prompt and paused-debugger completion paths, and add a regression test covering the engine and script editor. Co-Authored-By: Claude Opus 5.5 --- .../ManagedPowerShellSession.Bridge.cs | 7 +++++ .../ManagedPowerShellSession.Debugger.cs | 2 +- .../ManagedPowerShellSession.cs | 13 +++------ .../IsebergNativeContinuationTests.cs | 27 +++++++++++++++++++ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs index a88d93e..110b3f8 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs @@ -8,6 +8,13 @@ public sealed partial class ManagedPowerShellSession private static Iseberg.Core.CompletionResult ToCompletion(System.Management.Automation.CompletionResult result) => new(result.CompletionText, result.ListItemText, (Iseberg.Core.CompletionResultType)(long)result.ResultType, result.ToolTip); + // PowerShell reports ReplacementIndex/Length of -1 when it found nothing to replace + // (e.g. empty or whitespace-only input); anchor that as an empty span at the cursor. + private static CompletionSet ToCompletionSet(CommandCompletion result, int cursor) => + result.ReplacementIndex < 0 || result.ReplacementLength < 0 + ? new(cursor, 0, result.CompletionMatches.Select(ToCompletion).ToArray()) + : new(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); + internal SessionStatus Status() => new(State, Prompt, Version, LocalRunspaceId, RunspaceId, IsRunspacePushed, IsRemote, RemoteComputerName, IsDebuggerPaused, IsNestedPromptActive, NestedPrompt); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs index bfc221f..dac2e5d 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs @@ -245,7 +245,7 @@ private Task CompletePausedAsync(string text, int cursor, Cancell var command = new PSCommand().AddCommand("TabExpansion2").AddArgument(text).AddArgument(cursor); var result = Inspect(command).Select(value => value.BaseObject).OfType().FirstOrDefault() ?? throw new InvalidOperationException("PowerShell completion did not return a completion result."); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); + return ToCompletionSet(result, cursor); }, cancellationToken); public Task> GetBreakpointsAsync() => diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs index 5088197..62265c2 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs @@ -302,15 +302,10 @@ public Task SetBreakpointsAsync(string path, IEnumerable lines) => /// Completes text at a zero-based cursor offset in the current runspace or paused debugger context. public Task CompleteAsync(string text, int cursor, CancellationToken cancellationToken = default) => - State == SessionState.NestedPrompt ? NestedQueryAsync(shell => - { - var result = CommandCompletion.CompleteInput(text, cursor, null, shell); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); - }, cancellationToken) : State == SessionState.Debugging ? CompletePausedAsync(text, cursor, cancellationToken) : QueryAsync(shell => - { - var result = CommandCompletion.CompleteInput(text, cursor, null, shell); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); - }, cancellationToken); + State == SessionState.NestedPrompt + ? NestedQueryAsync(shell => ToCompletionSet(CommandCompletion.CompleteInput(text, cursor, null, shell), cursor), cancellationToken) + : State == SessionState.Debugging ? CompletePausedAsync(text, cursor, cancellationToken) + : QueryAsync(shell => ToCompletionSet(CommandCompletion.CompleteInput(text, cursor, null, shell), cursor), cancellationToken); public Task> GetCommandsAsync(CancellationToken cancellationToken = default) => QueryAsync>(shell => diff --git a/tests/Devolutions.Terminal.UI.Tests/IsebergNativeContinuationTests.cs b/tests/Devolutions.Terminal.UI.Tests/IsebergNativeContinuationTests.cs index 535c1bd..2e5fd75 100644 --- a/tests/Devolutions.Terminal.UI.Tests/IsebergNativeContinuationTests.cs +++ b/tests/Devolutions.Terminal.UI.Tests/IsebergNativeContinuationTests.cs @@ -845,6 +845,33 @@ await WaitForAsync(() => session.Engine.State == SessionState.Ready && connectio await SubmitTokenAsync(tab, "'DT-INVALID-STILL-USABLE'", "DT-INVALID-STILL-USABLE"); }); + [AvaloniaTheory] + [InlineData("")] + [InlineData(" ")] + public async Task CompletionWithNothingToReplaceAnchorsEmptySpanAtCaret(string text) + { + await InitializeRuntimeAsync(); + await EmptyCompletionCoreAsync(text); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static Task EmptyCompletionCoreAsync(string text) => WithNativeHostAsync(async tab => + { + // PowerShell returns ReplacementIndex/Length = -1 here; the session must not pass that on. + var session = Assert.Single(tab.Workbench.Workbench.Sessions); + var completion = await session.Engine.CompleteAsync(text, text.Length).WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(text.Length, completion.Start); + Assert.Equal(0, completion.Length); + Assert.Empty(completion.Matches); + + var editor = tab.Workbench.FindControl("ScriptEditorControl")!; + editor.Document.Text = text; + editor.CaretOffset = text.Length; + var result = await editor.RequestCompletionAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(new Iseberg.Editor.EditorTextSpan(text.Length, 0), result.ReplacementSpan); + Assert.True(result.Items.IsEmpty); + }); + private static Popup ConsolePopup(DtIsebergConsole console) => Assert.Single(console.View.GetLogicalDescendants().OfType(), popup => ReferenceEquals(popup.PlacementTarget, console.Terminal)); From 7847439fe1ca8a3f8ad5946d3ebcda52a618901d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 13:02:46 -0400 Subject: [PATCH 3/5] Fix tab close button not responding to clicks #56 made the tab header's drag handlers observe handled pointer events so dragging works on a Button. The press on the nested close X then also bubbled to the header, whose BeginTabDrag captured the pointer away from the close button, so it never raised Click and tabs could not be closed with the mouse. Skip drag tracking when the press starts on a nested button, and add a regression test that clicks a tab's close button with real pointer input. Co-Authored-By: Claude Opus 5.5 --- .../Views/MainWindow.axaml.cs | 10 ++++ .../WindowActionRegistrationTests.cs | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index b7c05b6..d3e861c 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -1801,6 +1801,16 @@ private void BeginTabDrag(TerminalTab tab, Control control, PointerPressedEventA return; } + // A press on a nested button (the close X) belongs to that button. This + // handler also sees handled events, so capturing here would steal the + // nested button's pointer capture and it would never raise Click. + if (e.Source is Visual source && + Avalonia.VisualTree.VisualExtensions.FindAncestorOfType