From 2b000c570e4453481f163a9032651d22bb75e3f6 Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Apr 2026 13:39:35 +0100 Subject: [PATCH 01/13] Add ModifierKey.AltGr --- .../AnsiHandling/KittyKeyboardPattern.cs | 3 + Terminal.Gui/Input/Keyboard/ModifierKey.cs | 3 + .../AnsiHandling/KittyKeyboardParsingTests.cs | 62 +++++++++++++++++++ .../KittyKeyboardPipelineTests.cs | 15 +++++ .../Views/TextFieldTests.cs | 18 ++++++ .../Views/TextView.InputTests.cs | 18 ++++++ 6 files changed, 119 insertions(+) diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index cf09a53825..063f4cb7f8 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -231,6 +231,9 @@ private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key { 57447, ModifierKey.RightShift }, { 57448, ModifierKey.RightCtrl }, { 57449, ModifierKey.RightAlt }, + // 57453 = ISO_Level3_Shift (AltGr). Treat it as a dedicated modifier so + // standalone AltGr does not fall through as a printable Private Use Area rune. + { 57453, ModifierKey.AltGr }, { 57450, ModifierKey.RightSuper }, { 57451, ModifierKey.RightHyper } diff --git a/Terminal.Gui/Input/Keyboard/ModifierKey.cs b/Terminal.Gui/Input/Keyboard/ModifierKey.cs index dbd0dabf94..70af63e615 100644 --- a/Terminal.Gui/Input/Keyboard/ModifierKey.cs +++ b/Terminal.Gui/Input/Keyboard/ModifierKey.cs @@ -47,6 +47,9 @@ public enum ModifierKey /// Right Alt. RightAlt, + /// AltGr / ISO Level 3 Shift. + AltGr, + /// Super / Windows / Cmd key (side not distinguished). Super, diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs index 0916a61a22..2c5e9e684c 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs @@ -190,6 +190,17 @@ public void KittyPattern_RightAlt_Standalone () Assert.Equal (ModifierKey.RightAlt, key.ModifierKey); } + [Fact] + public void KittyPattern_AltGr_Standalone () + { + // ESC[57453u = AltGr / ISO_Level3_Shift + Key? key = _pattern.GetKey ("\u001b[57453u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.AltGr, key.ModifierKey); + } + [Fact] public void KittyPattern_CapsLock_Standalone () { @@ -224,6 +235,57 @@ public void KittyPattern_ModifierKey_WithEventType_Release () Assert.Equal (KeyEventType.Release, key.EventType); } + [Fact] + public void KittyPattern_AltGr_WithEventType_Release () + { + // ESC[57453;1:3u = AltGr / ISO_Level3_Shift, event type 3 (release) + Key? key = _pattern.GetKey ("\u001b[57453;1:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.AltGr, key.ModifierKey); + Assert.Equal (KeyEventType.Release, key.EventType); + } + + [Fact] + public void KittyPattern_LeftAlt_WithCtrlModifier_PreservesBothStates () + { + Key? key = _pattern.GetKey ("\u001b[57443;5u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.False (key.IsAlt); + Assert.Equal (KeyEventType.Press, key.EventType); + } + + [Fact] + public void KittyPattern_LeftAlt_Release_WithCtrlAndAltModifiers_PreservesBothStates () + { + Key? key = _pattern.GetKey ("\u001b[57443;7:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.True (key.IsAlt); + Assert.Equal (KeyEventType.Release, key.EventType); + } + + [Fact] + public void KittyPattern_LeftCtrl_Release_WithCtrlModifier_PreservesState () + { + Key? key = _pattern.GetKey ("\u001b[57442;5:3u"); + + Assert.NotNull (key); + Assert.True (key.IsModifierOnly); + Assert.Equal (ModifierKey.LeftCtrl, key.ModifierKey); + Assert.True (key.IsCtrl); + Assert.False (key.IsAlt); + Assert.Equal (KeyEventType.Release, key.EventType); + } + [Fact] public void KittyPattern_NonModifierKey_IsNotModifierOnly () { diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index bd3a2c1b76..e4fbafc7f8 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -249,6 +249,7 @@ public void Pipeline_RightAlt_Press () [InlineData ("\x1b[57447u", ModifierKey.RightShift)] [InlineData ("\x1b[57448u", ModifierKey.RightCtrl)] [InlineData ("\x1b[57449u", ModifierKey.RightAlt)] + [InlineData ("\x1b[57453u", ModifierKey.AltGr)] public void Pipeline_ModifierPress_RaisesKeyDown (string sequence, ModifierKey expectedModifier) { // Standalone modifier press should raise app.Keyboard.KeyDown @@ -269,6 +270,7 @@ public void Pipeline_ModifierPress_RaisesKeyDown (string sequence, ModifierKey e [InlineData ("\x1b[57447;1:3u", ModifierKey.RightShift)] [InlineData ("\x1b[57448;1:3u", ModifierKey.RightCtrl)] [InlineData ("\x1b[57449;1:3u", ModifierKey.RightAlt)] + [InlineData ("\x1b[57453;1:3u", ModifierKey.AltGr)] public void Pipeline_ModifierRelease_RaisesKeyUp (string sequence, ModifierKey expectedModifier) { // Standalone modifier release should raise app.Keyboard.KeyUp @@ -281,6 +283,19 @@ public void Pipeline_ModifierRelease_RaisesKeyUp (string sequence, ModifierKey e Assert.Equal (KeyEventType.Release, up [0].EventType); } + [Fact] + public void Pipeline_LeftAltPress_WithCtrlModifier_PreservesBothStates () + { + (List down, List up) = InjectRawSequence ("\x1b[57443;5u"); + + Assert.Single (down); + Assert.True (down [0].IsModifierOnly); + Assert.Equal (ModifierKey.LeftAlt, down [0].ModifierKey); + Assert.True (down [0].IsCtrl); + Assert.False (down [0].IsAlt); + Assert.Empty (up); + } + #endregion #region CSI ~ and Cursor Keys diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs index 576ca584b8..041b0d94e0 100644 --- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs @@ -277,6 +277,24 @@ public void KittyAssociatedText_ShiftedPrintableKey_InsertsAssociatedText () top.Dispose (); } + [Fact] + public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key kittyKey = new () { ModifierKey = ModifierKey.AltGr }; + + Assert.False (top.NewKeyDownEvent (kittyKey)); + Assert.Equal (string.Empty, tf.Text); + + top.Dispose (); + } + [Fact] public void ShiftedDigitKey_WithoutKittyMetadata_InsertsBaseDigit () { diff --git a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs index a67201a854..03c3861413 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs @@ -201,6 +201,24 @@ public void KittyAssociatedText_ShiftedPrintableKey_InsertsAssociatedText () Assert.Equal (new Point (1, 0), tv.InsertionPoint); } + [Fact] + public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key kittyKey = new () { ModifierKey = ModifierKey.AltGr }; + + Assert.False (tv.NewKeyDownEvent (kittyKey)); + Assert.Equal (string.Empty, tv.Text); + Assert.Equal (Point.Empty, tv.InsertionPoint); + } + [Fact] public void InsertText_GraphemeSequence_PreservesSingleGrapheme () { From 5f4fbc352d65b2f26cb73aac2ae9de415fcb27d2 Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Apr 2026 13:43:38 +0100 Subject: [PATCH 02/13] Fixes #4867. Invalid global.json file with "sdk" version --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index f823b7afc1..ceedb4c7af 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.0", + "version": "10.0.100", "rollForward": "latestMinor" }, "test": { From f9d3d18b9bacddd9b5b3dc8e44201bbb8f213ff9 Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Apr 2026 15:26:13 +0100 Subject: [PATCH 03/13] Fix pattern not accepting empty fields --- .../AnsiHandling/KittyKeyboardPattern.cs | 2 +- .../AnsiHandling/KittyKeyboardParsingTests.cs | 52 ++++++++++++++++ .../KittyKeyboardPipelineTests.cs | 13 ++++ .../Views/TextFieldTests.cs | 60 +++++++++++++++++++ .../Views/TextView.InputTests.cs | 57 ++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index 063f4cb7f8..12fc6e718b 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -10,7 +10,7 @@ namespace Terminal.Gui.Drivers; /// public class KittyKeyboardPattern : AnsiKeyboardParserPattern { - private readonly Regex _pattern = new (@"^\u001b\[(\d+)(?::(\d+))?(?::(\d+))?(?:;([^;u]*))?(?:;([^u]+))?u$"); + private readonly Regex _pattern = new (@"^\u001b\[(\d+)(?::(\d*))?(?::(\d*))?(?:;([^;u]*))?(?:;([^u]+))?u$"); private readonly Dictionary _functionalKeyMap = new () { diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs index 2c5e9e684c..6ba6d25680 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs @@ -522,6 +522,58 @@ public void KittyPattern_AlternateKeys_FunctionalKey () Assert.Equal ((KeyCode)13, key.BaseLayoutKeyCode); } + [Fact] + public void KittyPattern_EmptyFields_WithAssociatedText () + { + // ESC[64::50;;64u = '@' with empty shifted field, base layout '2', empty modifiers, associated text '@' + Key? key = _pattern.GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.Equal (new Key ('@'), key); + Assert.Equal (KeyCode.Null, key.ShiftedKeyCode); + Assert.Equal ((KeyCode)50, key.BaseLayoutKeyCode); + Assert.Equal ("@", key.AssociatedText); + } + + [Fact] + public void KittyPattern_AltGr5_Press_ReturnsEuroGrapheme () + { + // ESC[8364;1:1u = Euro symbol, no modifiers, press + Key? key = _pattern.GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Press, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + + [Fact] + public void KittyPattern_AltGrE_EuroKey_Press_ReturnsEuroGrapheme () + { + // ESC[8364;1:1u = AltGr+E on many layouts, which sends the euro codepoint 8364. + Key? key = _pattern.GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Press, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + + [Fact] + public void KittyPattern_AltGrE_EuroKey_Release_ReturnsEuroGrapheme () + { + // ESC[8364;1:3u = Euro symbol, no modifiers, release + Key? key = _pattern.GetKey ("\u001b[8364;1:3u"); + + Assert.NotNull (key); + Assert.Equal (KeyEventType.Release, key.EventType); + Assert.Equal ((KeyCode)8364, key.KeyCode); + Assert.Equal ("€", key.AsGrapheme); + Assert.Equal ("€", key.GetPrintableText ()); + } + #endregion #region Kitty Flags Validation diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index e4fbafc7f8..f1478073c8 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -57,6 +57,19 @@ public void Pipeline_Press_RaisesKeyDown () Assert.Empty (up); } + [Fact] + public void Pipeline_AltGrE_Press_RaisesEuroKeyDown () + { + // ESC[8364;1:1u = Euro symbol press + (List down, List up) = InjectRawSequence ("\x1b[8364;1:1u"); + + Assert.Single (down); + Assert.Equal (KeyEventType.Press, down [0].EventType); + Assert.Equal ((KeyCode)8364, down [0].KeyCode); + Assert.Equal ("€", down [0].AsGrapheme); + Assert.Empty (up); + } + // Copilot - Opus 4.6 [Fact] public void Pipeline_Repeat_RaisesKeyDown () diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs index 041b0d94e0..10fe37032a 100644 --- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs @@ -295,6 +295,66 @@ public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () top.Dispose (); } + [Fact] + public void KittyAltGr5_InsertsEuroSymbol () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("€", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + + [Fact] + public void KittyAltGrE_InsertsEuroSymbol () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("€", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + + [Fact] + public void KittyAltGr2_InsertsAtSign () + { + Runnable top = new (); + TextField tf = new () { Width = 10 }; + top.Add (tf); + tf.SetFocus (); + tf.ClearAllSelection (); + tf.InsertionPoint = 0; + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.True (top.NewKeyDownEvent (key)); + Assert.Equal ("@", tf.Text); + Assert.Equal (1, tf.InsertionPoint); + + top.Dispose (); + } + [Fact] public void ShiftedDigitKey_WithoutKittyMetadata_InsertsBaseDigit () { diff --git a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs index 03c3861413..9d49854c2e 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs @@ -219,6 +219,63 @@ public void KittyAltGrModifierOnly_DoesNotInsertPrivateUseRune () Assert.Equal (Point.Empty, tv.InsertionPoint); } + [Fact] + public void KittyAltGr5_InsertsEuroSymbol () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("€", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + + [Fact] + public void KittyAltGrE_InsertsEuroSymbol () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[8364;1:1u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("€", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + + [Fact] + public void KittyAltGr2_InsertsAtSign () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 2 }; + + runnable.Add (tv); + app.Begin (runnable); + + Key? key = new KittyKeyboardPattern ().GetKey ("\u001b[64::50;;64u"); + + Assert.NotNull (key); + Assert.True (tv.NewKeyDownEvent (key)); + Assert.Equal ("@", tv.Text); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + } + [Fact] public void InsertText_GraphemeSequence_PreservesSingleGrapheme () { From 7b19d8cd01855f589bb8b4929b4a7837ef58ea28 Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Apr 2026 19:55:59 +0100 Subject: [PATCH 04/13] Add pad keys --- .../Drivers/AnsiHandling/KittyKeyboardPattern.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index 12fc6e718b..925849d8f8 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -53,7 +53,17 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern { 57384, Key.F21 }, { 57385, Key.F22 }, { 57386, Key.F23 }, - { 57387, Key.F24 } + { 57387, Key.F24 }, + { 57417, Key.CursorLeft }, + { 57418, Key.CursorRight }, + { 57419, Key.CursorUp }, + { 57420, Key.CursorDown }, + { 57421, Key.PageUp }, + { 57422, Key.PageDown }, + { 57423, Key.Home }, + { 57424, Key.End }, + { 57425, Key.InsertChar }, + { 57426, Key.Delete } }; /// From d7e305ca594c0a263ff8ed602b657a40828cb7ef Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 00:18:28 +0100 Subject: [PATCH 05/13] Add keypad unit tests --- .../KittyKeyboardPipelineTests.cs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index f1478073c8..d1176d7c6c 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -573,5 +573,38 @@ public void Pipeline_ValidAfterInvalid_StillWorks () Assert.Equal (KeyEventType.Release, keyUpEvents [0].EventType); } - #endregion + + // Copilot - Opus 4.6 + // Theory test for alternative kitty code points (57417-57426) + [Theory] + [InlineData (57417, nameof (Key.CursorLeft))] + [InlineData (57418, nameof (Key.CursorRight))] + [InlineData (57419, nameof (Key.CursorUp))] + [InlineData (57420, nameof (Key.CursorDown))] + [InlineData (57421, nameof (Key.PageUp))] + [InlineData (57422, nameof (Key.PageDown))] + [InlineData (57423, nameof (Key.Home))] + [InlineData (57424, nameof (Key.End))] + [InlineData (57425, nameof (Key.InsertChar))] + [InlineData (57426, nameof (Key.Delete))] + public void Alternative_KittyCodePoints_Map_To_Correct_Keys (int kittyCode, string expectedKeyName) + { + // Arrange - Build the kitty sequence for the code point + string sequence = $"\x1b[{kittyCode}u"; // ESC[codePointu (press event, no modifiers) + + // Act + (List down, List up) = InjectRawSequence (sequence); + + // Assert - Should have exactly one key down event + Assert.Single (down); + + // Get the expected key by name from the Key class + System.Reflection.PropertyInfo? prop = typeof (Key).GetProperty (expectedKeyName); + Assert.NotNull (prop); + Key expectedKey = (Key)prop!.GetValue (null)!; + + // Verify the mapped key matches the expected key + Assert.Equal (expectedKey.KeyCode, down [0].KeyCode); + Assert.Empty (up); + } } From 9b29a7494d5d7052fb3b41558c6229b3a8569d6d Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 00:21:55 +0100 Subject: [PATCH 06/13] Fixes #4918. Some keys print twice in the terminal on Windows with kitty enabled --- .../App/MainLoop/MainLoopCoordinator.cs | 7 +++++ Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs | 29 +++++++++++++++++++ .../KittyKeyboardPipelineTests.cs | 17 +++++++++++ 3 files changed, 53 insertions(+) diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index e3d7c11f6e..bbaef58150 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -198,6 +198,13 @@ private void BuildDriverIfPossible (IApplication? app) ansiOutput.EnableKittyKeyboard (result.EnabledFlags); _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); + + // Enable kitty protocol deduplication in AnsiInput if applicable + if (_input is AnsiInput ansiInput) + { + ansiInput.KittyProtocolEnabled = true; + } + Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index 5c165b824a..db5d4bc83f 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -68,6 +68,18 @@ public class AnsiInput : InputImpl, ITestableInput // Queue for storing injected input that will be returned by Peek/Read private readonly ConcurrentQueue _testInput = new (); + private bool _kittyProtocolEnabled; + private char? _previousWindowsVTLastChar; + + /// + /// Gets or sets whether the kitty keyboard protocol is enabled for this input stream. + /// + internal bool KittyProtocolEnabled + { + get => _kittyProtocolEnabled; + set => _kittyProtocolEnabled = value; + } + private int _peekCallCount; /// @@ -205,6 +217,23 @@ public override IEnumerable Read () string text = _windowsVTInput!.ConsoleInputEncoding.GetString (buffer, 0, bytesRead); + if (_kittyProtocolEnabled + && _previousWindowsVTLastChar is { } lastChar + && text.Length > 0 + && text [0] == lastChar) + { + text = text [1..]; + } + + if (text.Length == 0) + { + _previousWindowsVTLastChar = null; + + yield break; + } + + _previousWindowsVTLastChar = text [^1]; + //Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}"); foreach (char ch in text) diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index d1176d7c6c..8fe79a72b2 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -573,6 +573,22 @@ public void Pipeline_ValidAfterInvalid_StillWorks () Assert.Equal (KeyEventType.Release, keyUpEvents [0].EventType); } + // Copilot - Opus 4.6 + [Fact] + public void Pipeline_KittyPrintableKey () + { + // Test that kitty CSI u sequence produces the expected key event + (List down, List up) = InjectRawSequence ( + "\x1b[171;2:1u" // ESC[171;2:1u = '«'(171) + Shift(2), press(1) - kitty sequence + ); + + // Should have one key event from kitty + Assert.Single (down); + // For Shift+«, the kitty sequence should produce a key with KeyCode = 171 and IsShift = true + Assert.Equal (171, down [0].AsRune.Value); + Assert.True (down [0].IsShift); + Assert.Empty (up); + } // Copilot - Opus 4.6 // Theory test for alternative kitty code points (57417-57426) @@ -608,3 +624,4 @@ public void Alternative_KittyCodePoints_Map_To_Correct_Keys (int kittyCode, stri Assert.Empty (up); } } +#endregion From c969d3fa2d130897c1df93e4c35f90c8790d46af Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 9 Apr 2026 18:13:37 -0600 Subject: [PATCH 07/13] Refactor Kitty keyboard protocol enablement Refactored Kitty keyboard protocol handling to use KittyKeyboardFlags in AnsiInput, added EnableKittyKeyboard method, and updated DriverImpl to synchronize enabled flags across input and output. Removed obsolete KittyProtocolEnabled property and improved logging/tracing for clarity. --- .../App/MainLoop/MainLoopCoordinator.cs | 58 ++++++++++--------- Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs | 27 +++++---- Terminal.Gui/Drivers/DriverImpl.cs | 11 ++++ 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index bbaef58150..0670995b82 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -169,7 +169,9 @@ private void BuildDriverIfPossible (IApplication? app) return; } - Logging.Trace ($"app: SetDefaultAttribute ({new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))})"); + Logging.Trace ($"app: SetDefaultAttribute ({ + new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0)) + })"); _driver.SetDefaultAttribute (new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))); }); @@ -183,32 +185,34 @@ private void BuildDriverIfPossible (IApplication? app) try { KittyKeyboardProtocolDetector kittyKeyboardDetector = new (_driver); + kittyKeyboardDetector.Detect (result => - { - _driver.SetKittyKeyboardProtocol (result); - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Probe complete: Supported={result.IsSupported}, SupportedFlags={result.SupportedFlags}, EnabledFlags={result.EnabledFlags}"); - - if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) - { - Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); - return; - } - - ansiOutput.EnableKittyKeyboard (result.EnabledFlags); - _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); - - // Enable kitty protocol deduplication in AnsiInput if applicable - if (_input is AnsiInput ansiInput) - { - ansiInput.KittyProtocolEnabled = true; - } - - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); - }); + { + _driver.SetKittyKeyboardProtocol (result); + + Trace.Lifecycle (app?.MainThreadId?.ToString (), + "KittyKeyboard", + $"Probe complete: Supported={ + result.IsSupported + }, SupportedFlags={ + result.SupportedFlags + }, EnabledFlags={ + result.EnabledFlags + }"); + + if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) + { + Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); + + return; + } + + _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); + + Trace.Lifecycle (app?.MainThreadId?.ToString (), + "KittyKeyboard", + $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); + }); } catch (Exception ex) { @@ -262,7 +266,7 @@ private void RunInput (IApplication? app) if (_stopCalled) { - Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", $"Input loop exited cleanly"); + Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", "Input loop exited cleanly"); } else { diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index db5d4bc83f..30bb19b6d9 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -68,18 +68,8 @@ public class AnsiInput : InputImpl, ITestableInput // Queue for storing injected input that will be returned by Peek/Read private readonly ConcurrentQueue _testInput = new (); - private bool _kittyProtocolEnabled; private char? _previousWindowsVTLastChar; - /// - /// Gets or sets whether the kitty keyboard protocol is enabled for this input stream. - /// - internal bool KittyProtocolEnabled - { - get => _kittyProtocolEnabled; - set => _kittyProtocolEnabled = value; - } - private int _peekCallCount; /// @@ -217,7 +207,7 @@ public override IEnumerable Read () string text = _windowsVTInput!.ConsoleInputEncoding.GetString (buffer, 0, bytesRead); - if (_kittyProtocolEnabled + if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None && _previousWindowsVTLastChar is { } lastChar && text.Length > 0 && text [0] == lastChar) @@ -387,6 +377,19 @@ private void FlushInput () } } + private KittyKeyboardFlags _enabledKittyKeyboardFlags; + + /// + /// Enables kitty keyboard progressive enhancement flags for the active terminal. + /// + /// The kitty keyboard flags to enable. + internal void EnableKittyKeyboard (KittyKeyboardFlags enabledFlags) + { + _enabledKittyKeyboardFlags = enabledFlags; + + Trace.Lifecycle (nameof (AnsiOutput), "KittyKeyboard", $"Input enabled: {enabledFlags}"); + } + // Will be called on the main loop thread. /// public void InjectInput (char input) => _testInput.Enqueue (input); @@ -426,4 +429,6 @@ public override void Dispose () // ignore exceptions during disposal } } + + } diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 3edbf2ec30..98612bb8a7 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -382,6 +382,17 @@ public Attribute SetAttribute (Attribute newAttribute) internal void SetKittyKeyboardEnabledFlags (KittyKeyboardFlags enabledFlags) { KittyKeyboardProtocol.EnabledFlags = enabledFlags; + + + if ((_componentFactory as AnsiComponentFactory)?.CreateOutput() is AnsiOutput { } output) + { + output.EnableKittyKeyboard (enabledFlags); + } + + if ((_componentFactory as AnsiComponentFactory)?.CreateInput () is AnsiInput { } input) + { + input.EnableKittyKeyboard (enabledFlags); + } } /// Event fired when a key is pressed down. From 7895d20af29cdfcd6c0422377aa56f14a51a4022 Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 12:04:37 +0100 Subject: [PATCH 08/13] Rename to _previousLastChar --- Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index 30bb19b6d9..2cbd1f3cd1 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -68,7 +68,7 @@ public class AnsiInput : InputImpl, ITestableInput // Queue for storing injected input that will be returned by Peek/Read private readonly ConcurrentQueue _testInput = new (); - private char? _previousWindowsVTLastChar; + private char? _previousLastChar; private int _peekCallCount; @@ -208,7 +208,7 @@ public override IEnumerable Read () string text = _windowsVTInput!.ConsoleInputEncoding.GetString (buffer, 0, bytesRead); if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None - && _previousWindowsVTLastChar is { } lastChar + && _previousLastChar is { } lastChar && text.Length > 0 && text [0] == lastChar) { @@ -217,12 +217,12 @@ public override IEnumerable Read () if (text.Length == 0) { - _previousWindowsVTLastChar = null; + _previousLastChar = null; yield break; } - _previousWindowsVTLastChar = text [^1]; + _previousLastChar = text [^1]; //Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}"); From b8a66947394f76a81b181b1f160498140c4bace8 Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 14:01:51 +0100 Subject: [PATCH 09/13] Add key deduplication tests --- .../AnsiInputCedillaDeduplicationTests.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs new file mode 100644 index 0000000000..80d09a9181 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace DriverTests.AnsiHandling; + +/// +/// Tests for cedilla character deduplication in AnsiInput when kitty protocol is enabled. +/// These tests validate that the read-time deduplication logic correctly strips duplicate characters +/// that result from Windows VT input emitting both kitty sequences and raw characters. +/// +[Collection ("Driver Tests")] +public class AnsiInputCedillaDeduplicationTests +{ + // Copilot - Opus 4.6 + // Test that the EnableKittyKeyboard method exists and is callable + [Fact] + public void AnsiInput_EnableKittyKeyboard_MethodIsAccessible () + { + // Arrange + AnsiInput ansiInput = new (); + + // Act - Access the internal method via reflection + MethodInfo? method = typeof (AnsiInput).GetMethod ("EnableKittyKeyboard", BindingFlags.NonPublic | BindingFlags.Instance); + + // Assert - Method exists and can be accessed + Assert.NotNull (method); + + // Act - Invoke it with the DisambiguateEscapeCodes flag + try + { + method.Invoke (ansiInput, [KittyKeyboardFlags.DisambiguateEscapeCodes]); + + // Success - method was callable + } + catch (Exception ex) + { + Assert.Fail ($"Failed to invoke EnableKittyKeyboard: {ex.Message}"); + } + } + + // Copilot - Opus 4.6 + // Test that Read() method can be called via reflection + [Fact] + public void AnsiInput_Read_MethodExists_AndReturnsEnumerable () + { + // Arrange + AnsiInput ansiInput = new (); + ConcurrentQueue testQueue = new (); + + // Initialize with test input + testQueue.Enqueue ('a'); + testQueue.Enqueue ('b'); + testQueue.Enqueue ('c'); + + // Use reflection to set the internal test queue + FieldInfo? fieldTestInput = typeof (AnsiInput).GetField ("_testInput", BindingFlags.NonPublic | BindingFlags.Instance); + + if (fieldTestInput != null) + { + fieldTestInput.SetValue (ansiInput, testQueue); + } + + // Act - Call Read() method + MethodInfo? readMethod = typeof (AnsiInput).GetMethod ("Read", BindingFlags.Public | BindingFlags.Instance); + + Assert.NotNull (readMethod); + + // Read returns IEnumerable + var result = readMethod.Invoke (ansiInput, null) as System.Collections.IEnumerable; + + // Assert + Assert.NotNull (result); + + List chars = new (); + + foreach (char ch in result) + { + chars.Add (ch); + } + + Assert.Equal (new [] { 'a', 'b', 'c' }, chars); + } + + // Copilot - Opus 4.6 + // Test that the _previousLastChar field is used for deduplication + [Fact] + public void AnsiInput_PreviousLastChar_FieldExists () + { + // Arrange and Act - Access the field via reflection + FieldInfo? field = typeof (AnsiInput).GetField ("_previousLastChar", BindingFlags.NonPublic | BindingFlags.Instance); + + // Assert - Field exists (used for deduplication) + Assert.NotNull (field); + Assert.Equal (typeof (char?), field.FieldType); + } + + // Copilot - Opus 4.6 + // Documentation test explaining the deduplication mechanism + [Fact] + public void AnsiInput_Deduplication_MechanismIsDocumented () + { + /* + * DEDUPLICATION MECHANISM FOR ç (CEDILLA) AND OTHER PRINTABLE CHARS: + * + * When kitty keyboard protocol is enabled on Windows with VT input: + * + * 1. ReadFile() may return BOTH: + * - Kitty CSI u sequence: "\x1b[231;1:1u" (for ç) + * - Raw character: "ç" (Windows sends the actual character too) + * + * 2. These come in sequential ReadFile() calls: + * - First call: Returns "\x1b[231;1:1u" + * - Second call: Returns "ç" (the raw char) + * + * 3. Read() method deduplicates by tracking _previousLastChar: + * - Saves the last char from first read: 'u' + * - On next call, checks if incoming text starts with same char + * - If yes, strips the first character from the incoming text + * + * 4. Implementation in AnsiInput.Read() (WindowsVT case): + * ```csharp + * if (_kittyProtocolEnabled + * && _previousLastChar is { } lastChar + * && text.Length > 0 + * && text[0] == lastChar) + * { + * text = text[1..]; // Strip the duplicate first char + * } + * _previousLastChar = text[^1]; // Save last char for next read + * ``` + * + * 5. This prevents double-insertion of printable characters when both + * kitty sequence and raw character are received consecutively. + * + * 6. The check is guarded by _enabledKittyKeyboardFlags so deduplication + * only happens when kitty protocol is explicitly enabled. + */ + + // Verify the fields exist that implement this mechanism + FieldInfo? lastCharField = typeof (AnsiInput).GetField ("_previousLastChar", BindingFlags.NonPublic | BindingFlags.Instance); + + FieldInfo? kittyFlagsField = typeof (AnsiInput).GetField ("_enabledKittyKeyboardFlags", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull (lastCharField); + Assert.NotNull (kittyFlagsField); + } +} From 84f0322f0b357de164066030beddb7a3455b6a1b Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 21:43:19 +0100 Subject: [PATCH 10/13] Fix kitty keyboard protocol enablement --- Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs | 7 ++++++- Terminal.Gui/Drivers/DriverImpl.cs | 7 +------ .../Application/MainLoopCoordinatorTests.cs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index 0670995b82..a79099059b 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -207,7 +207,12 @@ private void BuildDriverIfPossible (IApplication? app) return; } - _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); + _driver.SetKittyKeyboardEnabledFlags (_driver.KittyKeyboardProtocol.EnabledFlags); + + if (_input is AnsiInput ansiInput) + { + ansiInput.EnableKittyKeyboard (_driver.KittyKeyboardProtocol.EnabledFlags); + } Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 98612bb8a7..fb0eea13e3 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -383,16 +383,11 @@ internal void SetKittyKeyboardEnabledFlags (KittyKeyboardFlags enabledFlags) { KittyKeyboardProtocol.EnabledFlags = enabledFlags; - - if ((_componentFactory as AnsiComponentFactory)?.CreateOutput() is AnsiOutput { } output) + if (_output is AnsiOutput output) { output.EnableKittyKeyboard (enabledFlags); } - if ((_componentFactory as AnsiComponentFactory)?.CreateInput () is AnsiInput { } input) - { - input.EnableKittyKeyboard (enabledFlags); - } } /// Event fired when a key is pressed down. diff --git a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs index f3f1360e5a..904d845984 100644 --- a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs @@ -243,7 +243,7 @@ public async Task StartInputTaskAsync_DetectsKittyKeyboard_WhenTerminalResponds // In degraded mode (no real terminal), enable/disable are no-ops, // but detection still succeeds via injected response. - Assert.Equal (KittyKeyboardFlags.None, driver.KittyKeyboardProtocol.EnabledFlags); + Assert.Equal ((KittyKeyboardFlags)31, driver.KittyKeyboardProtocol.EnabledFlags); coordinator.Stop (); } From 0f2cf32b4b93ea6d9bfd0ae91a0824226aed1c4c Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 10 Apr 2026 23:29:11 +0100 Subject: [PATCH 11/13] Fix deduplication for search anywhere --- Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index 2cbd1f3cd1..8458bf938d 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -209,10 +209,15 @@ public override IEnumerable Read () if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None && _previousLastChar is { } lastChar - && text.Length > 0 - && text [0] == lastChar) + && text.Length > 0) { - text = text [1..]; + int index = text.IndexOf (lastChar); + + if (index >= 0) + { + text = text.Remove (index, 1); + _previousLastChar = null; + } } if (text.Length == 0) @@ -222,7 +227,22 @@ public override IEnumerable Read () yield break; } - _previousLastChar = text [^1]; + if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None) + { + // Always search for standalone characters (not part of ANSI sequences) + // Split by sequences and find the last non-empty part + string [] parts = System.Text.RegularExpressions.Regex.Split (text, @"\x1b[^a-zA-Z]*[a-zA-Z]"); + string lastStandalonePart = parts.LastOrDefault (p => !string.IsNullOrEmpty (p)) ?? ""; + + if (!string.IsNullOrEmpty (lastStandalonePart)) + { + _previousLastChar = lastStandalonePart [^1]; + } + else + { + _previousLastChar = null; + } + } //Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}"); From 6efca4f759e043942da929580ffcf8efe9f791f2 Mon Sep 17 00:00:00 2001 From: BDisp Date: Sat, 11 Apr 2026 15:52:48 +0100 Subject: [PATCH 12/13] Remove all changes related with the #4918 issue --- .../App/MainLoop/MainLoopCoordinator.cs | 26 +--- Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs | 54 ------- Terminal.Gui/Drivers/DriverImpl.cs | 6 - .../Application/MainLoopCoordinatorTests.cs | 2 +- .../AnsiInputCedillaDeduplicationTests.cs | 147 ------------------ 5 files changed, 6 insertions(+), 229 deletions(-) delete mode 100644 Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index a79099059b..76230c7639 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -169,9 +169,7 @@ private void BuildDriverIfPossible (IApplication? app) return; } - Logging.Trace ($"app: SetDefaultAttribute ({ - new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0)) - })"); + Logging.Trace ($"app: SetDefaultAttribute ({new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))})"); _driver.SetDefaultAttribute (new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))); }); @@ -185,35 +183,21 @@ private void BuildDriverIfPossible (IApplication? app) try { KittyKeyboardProtocolDetector kittyKeyboardDetector = new (_driver); - kittyKeyboardDetector.Detect (result => { _driver.SetKittyKeyboardProtocol (result); - Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", - $"Probe complete: Supported={ - result.IsSupported - }, SupportedFlags={ - result.SupportedFlags - }, EnabledFlags={ - result.EnabledFlags - }"); + $"Probe complete: Supported={result.IsSupported}, SupportedFlags={result.SupportedFlags}, EnabledFlags={result.EnabledFlags}"); if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) { Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); - return; } - _driver.SetKittyKeyboardEnabledFlags (_driver.KittyKeyboardProtocol.EnabledFlags); - - if (_input is AnsiInput ansiInput) - { - ansiInput.EnableKittyKeyboard (_driver.KittyKeyboardProtocol.EnabledFlags); - } - + ansiOutput.EnableKittyKeyboard (result.EnabledFlags); + _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); @@ -271,7 +255,7 @@ private void RunInput (IApplication? app) if (_stopCalled) { - Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", "Input loop exited cleanly"); + Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", $"Input loop exited cleanly"); } else { diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index 8458bf938d..5c165b824a 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -68,8 +68,6 @@ public class AnsiInput : InputImpl, ITestableInput // Queue for storing injected input that will be returned by Peek/Read private readonly ConcurrentQueue _testInput = new (); - private char? _previousLastChar; - private int _peekCallCount; /// @@ -207,43 +205,6 @@ public override IEnumerable Read () string text = _windowsVTInput!.ConsoleInputEncoding.GetString (buffer, 0, bytesRead); - if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None - && _previousLastChar is { } lastChar - && text.Length > 0) - { - int index = text.IndexOf (lastChar); - - if (index >= 0) - { - text = text.Remove (index, 1); - _previousLastChar = null; - } - } - - if (text.Length == 0) - { - _previousLastChar = null; - - yield break; - } - - if (_enabledKittyKeyboardFlags != KittyKeyboardFlags.None) - { - // Always search for standalone characters (not part of ANSI sequences) - // Split by sequences and find the last non-empty part - string [] parts = System.Text.RegularExpressions.Regex.Split (text, @"\x1b[^a-zA-Z]*[a-zA-Z]"); - string lastStandalonePart = parts.LastOrDefault (p => !string.IsNullOrEmpty (p)) ?? ""; - - if (!string.IsNullOrEmpty (lastStandalonePart)) - { - _previousLastChar = lastStandalonePart [^1]; - } - else - { - _previousLastChar = null; - } - } - //Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}"); foreach (char ch in text) @@ -397,19 +358,6 @@ private void FlushInput () } } - private KittyKeyboardFlags _enabledKittyKeyboardFlags; - - /// - /// Enables kitty keyboard progressive enhancement flags for the active terminal. - /// - /// The kitty keyboard flags to enable. - internal void EnableKittyKeyboard (KittyKeyboardFlags enabledFlags) - { - _enabledKittyKeyboardFlags = enabledFlags; - - Trace.Lifecycle (nameof (AnsiOutput), "KittyKeyboard", $"Input enabled: {enabledFlags}"); - } - // Will be called on the main loop thread. /// public void InjectInput (char input) => _testInput.Enqueue (input); @@ -449,6 +397,4 @@ public override void Dispose () // ignore exceptions during disposal } } - - } diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index fb0eea13e3..3edbf2ec30 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -382,12 +382,6 @@ public Attribute SetAttribute (Attribute newAttribute) internal void SetKittyKeyboardEnabledFlags (KittyKeyboardFlags enabledFlags) { KittyKeyboardProtocol.EnabledFlags = enabledFlags; - - if (_output is AnsiOutput output) - { - output.EnableKittyKeyboard (enabledFlags); - } - } /// Event fired when a key is pressed down. diff --git a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs index 904d845984..f3f1360e5a 100644 --- a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs @@ -243,7 +243,7 @@ public async Task StartInputTaskAsync_DetectsKittyKeyboard_WhenTerminalResponds // In degraded mode (no real terminal), enable/disable are no-ops, // but detection still succeeds via injected response. - Assert.Equal ((KittyKeyboardFlags)31, driver.KittyKeyboardProtocol.EnabledFlags); + Assert.Equal (KittyKeyboardFlags.None, driver.KittyKeyboardProtocol.EnabledFlags); coordinator.Stop (); } diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs deleted file mode 100644 index 80d09a9181..0000000000 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputCedillaDeduplicationTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; - -namespace DriverTests.AnsiHandling; - -/// -/// Tests for cedilla character deduplication in AnsiInput when kitty protocol is enabled. -/// These tests validate that the read-time deduplication logic correctly strips duplicate characters -/// that result from Windows VT input emitting both kitty sequences and raw characters. -/// -[Collection ("Driver Tests")] -public class AnsiInputCedillaDeduplicationTests -{ - // Copilot - Opus 4.6 - // Test that the EnableKittyKeyboard method exists and is callable - [Fact] - public void AnsiInput_EnableKittyKeyboard_MethodIsAccessible () - { - // Arrange - AnsiInput ansiInput = new (); - - // Act - Access the internal method via reflection - MethodInfo? method = typeof (AnsiInput).GetMethod ("EnableKittyKeyboard", BindingFlags.NonPublic | BindingFlags.Instance); - - // Assert - Method exists and can be accessed - Assert.NotNull (method); - - // Act - Invoke it with the DisambiguateEscapeCodes flag - try - { - method.Invoke (ansiInput, [KittyKeyboardFlags.DisambiguateEscapeCodes]); - - // Success - method was callable - } - catch (Exception ex) - { - Assert.Fail ($"Failed to invoke EnableKittyKeyboard: {ex.Message}"); - } - } - - // Copilot - Opus 4.6 - // Test that Read() method can be called via reflection - [Fact] - public void AnsiInput_Read_MethodExists_AndReturnsEnumerable () - { - // Arrange - AnsiInput ansiInput = new (); - ConcurrentQueue testQueue = new (); - - // Initialize with test input - testQueue.Enqueue ('a'); - testQueue.Enqueue ('b'); - testQueue.Enqueue ('c'); - - // Use reflection to set the internal test queue - FieldInfo? fieldTestInput = typeof (AnsiInput).GetField ("_testInput", BindingFlags.NonPublic | BindingFlags.Instance); - - if (fieldTestInput != null) - { - fieldTestInput.SetValue (ansiInput, testQueue); - } - - // Act - Call Read() method - MethodInfo? readMethod = typeof (AnsiInput).GetMethod ("Read", BindingFlags.Public | BindingFlags.Instance); - - Assert.NotNull (readMethod); - - // Read returns IEnumerable - var result = readMethod.Invoke (ansiInput, null) as System.Collections.IEnumerable; - - // Assert - Assert.NotNull (result); - - List chars = new (); - - foreach (char ch in result) - { - chars.Add (ch); - } - - Assert.Equal (new [] { 'a', 'b', 'c' }, chars); - } - - // Copilot - Opus 4.6 - // Test that the _previousLastChar field is used for deduplication - [Fact] - public void AnsiInput_PreviousLastChar_FieldExists () - { - // Arrange and Act - Access the field via reflection - FieldInfo? field = typeof (AnsiInput).GetField ("_previousLastChar", BindingFlags.NonPublic | BindingFlags.Instance); - - // Assert - Field exists (used for deduplication) - Assert.NotNull (field); - Assert.Equal (typeof (char?), field.FieldType); - } - - // Copilot - Opus 4.6 - // Documentation test explaining the deduplication mechanism - [Fact] - public void AnsiInput_Deduplication_MechanismIsDocumented () - { - /* - * DEDUPLICATION MECHANISM FOR ç (CEDILLA) AND OTHER PRINTABLE CHARS: - * - * When kitty keyboard protocol is enabled on Windows with VT input: - * - * 1. ReadFile() may return BOTH: - * - Kitty CSI u sequence: "\x1b[231;1:1u" (for ç) - * - Raw character: "ç" (Windows sends the actual character too) - * - * 2. These come in sequential ReadFile() calls: - * - First call: Returns "\x1b[231;1:1u" - * - Second call: Returns "ç" (the raw char) - * - * 3. Read() method deduplicates by tracking _previousLastChar: - * - Saves the last char from first read: 'u' - * - On next call, checks if incoming text starts with same char - * - If yes, strips the first character from the incoming text - * - * 4. Implementation in AnsiInput.Read() (WindowsVT case): - * ```csharp - * if (_kittyProtocolEnabled - * && _previousLastChar is { } lastChar - * && text.Length > 0 - * && text[0] == lastChar) - * { - * text = text[1..]; // Strip the duplicate first char - * } - * _previousLastChar = text[^1]; // Save last char for next read - * ``` - * - * 5. This prevents double-insertion of printable characters when both - * kitty sequence and raw character are received consecutively. - * - * 6. The check is guarded by _enabledKittyKeyboardFlags so deduplication - * only happens when kitty protocol is explicitly enabled. - */ - - // Verify the fields exist that implement this mechanism - FieldInfo? lastCharField = typeof (AnsiInput).GetField ("_previousLastChar", BindingFlags.NonPublic | BindingFlags.Instance); - - FieldInfo? kittyFlagsField = typeof (AnsiInput).GetField ("_enabledKittyKeyboardFlags", BindingFlags.NonPublic | BindingFlags.Instance); - - Assert.NotNull (lastCharField); - Assert.NotNull (kittyFlagsField); - } -} From 39ba1ee6462837da246a80610bbe761f52e62d6a Mon Sep 17 00:00:00 2001 From: BDisp Date: Sat, 11 Apr 2026 15:59:26 +0100 Subject: [PATCH 13/13] Fix format as original --- .../App/MainLoop/MainLoopCoordinator.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index 76230c7639..e3d7c11f6e 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -184,24 +184,24 @@ private void BuildDriverIfPossible (IApplication? app) { KittyKeyboardProtocolDetector kittyKeyboardDetector = new (_driver); kittyKeyboardDetector.Detect (result => - { - _driver.SetKittyKeyboardProtocol (result); - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Probe complete: Supported={result.IsSupported}, SupportedFlags={result.SupportedFlags}, EnabledFlags={result.EnabledFlags}"); - - if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) - { - Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); - return; - } - - ansiOutput.EnableKittyKeyboard (result.EnabledFlags); - _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); - }); + { + _driver.SetKittyKeyboardProtocol (result); + Trace.Lifecycle (app?.MainThreadId?.ToString (), + "KittyKeyboard", + $"Probe complete: Supported={result.IsSupported}, SupportedFlags={result.SupportedFlags}, EnabledFlags={result.EnabledFlags}"); + + if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) + { + Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); + return; + } + + ansiOutput.EnableKittyKeyboard (result.EnabledFlags); + _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); + Trace.Lifecycle (app?.MainThreadId?.ToString (), + "KittyKeyboard", + $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); + }); } catch (Exception ex) {