From b93c0d9b390167477b954796f2c4f9cbaecaeb4f Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 30 Sep 2026 13:35:35 +0900 Subject: [PATCH 1/2] Add CancellationToken support to prompts - Add CancellationToken overloads for the options and configure based Input / Password / Confirm / Select / MultiSelect / List APIs and for Bind; existing signatures are unchanged - While a cancelable token is supplied, poll for available keys instead of blocking in ReadKey, and close the prompt and throw OperationCanceledException once the token is canceled - Pass the token through the binders emitted by the source generator via a new ModelBinderRegistry.Register overload - Document cancellation from code and correct the Ctrl+C default behavior description Closes #208 Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ docs/en/configuration.md | 20 ++++- docs/ja/configuration.md | 20 ++++- .../PromptBindableGenerator.cs | 14 +-- src/Sharprompt/Forms/FormBase.cs | 35 +++++++- src/Sharprompt/ModelBinderRegistry.cs | 14 ++- src/Sharprompt/Prompt.Basic.cs | 85 ++++++++++++++++--- src/Sharprompt/Prompt.Bind.cs | 15 +++- .../Binding/ModelBinderRegistryTests.cs | 49 ++++++++++- .../Forms/FormInteractionTests.cs | 54 ++++++++++++ .../Sharprompt.Tests/Sharprompt.Tests.csproj | 2 + 11 files changed, 282 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f4e09fc..efd13d7 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,11 @@ catch (PromptCanceledException ex) { Console.WriteLine("Prompt canceled"); } + +// Cancel from code with a CancellationToken (throws OperationCanceledException) +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + +var answer = Prompt.Confirm(options => options.Message = "Are you ready?", cts.Token); ``` ## Validators diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 751a124..e74637c 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -52,7 +52,7 @@ var name = Prompt.Input("What's your name?"); ## Cancellation Support -By default, pressing `Ctrl+C` returns the default value. You can change this behavior to throw an exception: +By default, pressing `Ctrl+C` or `Esc` exits the process. You can change this behavior to throw an exception: ```csharp Prompt.ThrowExceptionOnCancel = true; @@ -68,6 +68,24 @@ catch (PromptCanceledException ex) } ``` +### Canceling from code + +Every prompt accepts a `CancellationToken` through the overloads that take an options object or a configure delegate, and `Prompt.Bind` accepts one too. When the token is canceled while the prompt is waiting for input, the prompt is closed and an `OperationCanceledException` is thrown, regardless of `ThrowExceptionOnCancel`. + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + +try +{ + var name = Prompt.Input(options => options.Message = "What's your name?", cts.Token); + Console.WriteLine($"Hello, {name}!"); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Timed out"); +} +``` + ## Culture Set the culture for localized messages: diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md index eaeb6de..ff5c8fc 100644 --- a/docs/ja/configuration.md +++ b/docs/ja/configuration.md @@ -52,7 +52,7 @@ var name = Prompt.Input("What's your name?"); ## キャンセルのサポート -デフォルトでは、`Ctrl+C` を押すとデフォルト値が返されます。この動作を変更して例外をスローさせることができます: +デフォルトでは、`Ctrl+C` または `Esc` を押すとプロセスが終了します。この動作を変更して例外をスローさせることができます: ```csharp Prompt.ThrowExceptionOnCancel = true; @@ -68,6 +68,24 @@ catch (PromptCanceledException ex) } ``` +### コードからのキャンセル + +すべてのプロンプトは、オプションオブジェクトまたは構成デリゲートを受け取るオーバーロードで `CancellationToken` を指定できます。`Prompt.Bind` も同様です。入力待ちの間にトークンがキャンセルされると、プロンプトを閉じて `OperationCanceledException` をスローします。この動作は `ThrowExceptionOnCancel` の設定に関係ありません。 + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + +try +{ + var name = Prompt.Input(options => options.Message = "What's your name?", cts.Token); + Console.WriteLine($"Hello, {name}!"); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Timed out"); +} +``` + ## カルチャ ローカライズされたメッセージのカルチャを設定します: diff --git a/src/Sharprompt.SourceGenerator/PromptBindableGenerator.cs b/src/Sharprompt.SourceGenerator/PromptBindableGenerator.cs index d31a1a0..7680485 100644 --- a/src/Sharprompt.SourceGenerator/PromptBindableGenerator.cs +++ b/src/Sharprompt.SourceGenerator/PromptBindableGenerator.cs @@ -84,7 +84,7 @@ private static (string HintName, string Source) Transform(GeneratorAttributeSynt builder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); builder.AppendLine(" internal static void Register()"); builder.AppendLine(" {"); - builder.AppendLine(" global::Sharprompt.ModelBinderRegistry.Register<" + fullTypeName + ">(model =>"); + builder.AppendLine(" global::Sharprompt.ModelBinderRegistry.Register<" + fullTypeName + ">((model, cancellationToken) =>"); builder.AppendLine(" {"); for (var i = 0; i < properties.Count; i++) @@ -333,7 +333,7 @@ private static void EmitInput(StringBuilder builder, PropertyMetadata property, builder.AppendLine(indent + " options.DefaultValue = model." + property.Name + ";"); EmitValidators(builder, property, indent); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitPassword(StringBuilder builder, PropertyMetadata property, string indent) @@ -348,7 +348,7 @@ private static void EmitPassword(StringBuilder builder, PropertyMetadata propert } EmitValidators(builder, property, indent); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitConfirm(StringBuilder builder, PropertyMetadata property, string indent) @@ -357,7 +357,7 @@ private static void EmitConfirm(StringBuilder builder, PropertyMetadata property builder.AppendLine(indent + "{"); builder.AppendLine(indent + " options.Message = " + Quote(property.Message) + ";"); builder.AppendLine(indent + " options.DefaultValue = model." + property.Name + ";"); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitSelect(StringBuilder builder, PropertyMetadata property, string indent) @@ -372,7 +372,7 @@ private static void EmitSelect(StringBuilder builder, PropertyMetadata property, } builder.AppendLine(indent + " options.DefaultValue = model." + property.Name + ";"); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitMultiSelect(StringBuilder builder, PropertyMetadata property, string indent) @@ -387,7 +387,7 @@ private static void EmitMultiSelect(StringBuilder builder, PropertyMetadata prop } builder.AppendLine(indent + " options.DefaultValues = model." + property.Name + " ?? global::System.Array.Empty<" + property.TypeArg + ">();"); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitList(StringBuilder builder, PropertyMetadata property, string indent) @@ -397,7 +397,7 @@ private static void EmitList(StringBuilder builder, PropertyMetadata property, s builder.AppendLine(indent + " options.Message = " + Quote(property.Message) + ";"); builder.AppendLine(indent + " options.DefaultValues = model." + property.Name + " ?? global::System.Array.Empty<" + property.TypeArg + ">();"); EmitValidators(builder, property, indent); - builder.AppendLine(indent + "});"); + builder.AppendLine(indent + "}, cancellationToken);"); } private static void EmitValidators(StringBuilder builder, PropertyMetadata property, string indent) diff --git a/src/Sharprompt/Forms/FormBase.cs b/src/Sharprompt/Forms/FormBase.cs index f47892c..7bb647e 100644 --- a/src/Sharprompt/Forms/FormBase.cs +++ b/src/Sharprompt/Forms/FormBase.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Threading; using Sharprompt.Drivers; using Sharprompt.Internal; @@ -26,6 +27,8 @@ [new ConsoleKeyBinding(ConsoleKey.Escape)] = HandleEscape }; } + private static readonly TimeSpan s_keyPollingInterval = TimeSpan.FromMilliseconds(16); + private readonly IConsoleDriver _consoleDriver; private readonly FormRenderer _formRenderer; private readonly PromptConfiguration _configuration; @@ -42,13 +45,15 @@ [new ConsoleKeyBinding(ConsoleKey.Escape)] = HandleEscape public void Dispose() => _consoleDriver.Dispose(); - public T Start() + public T Start(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + while (true) { _formRenderer.Render(InputTemplate); - if (!TryGetResult(out var result)) + if (!TryGetResult(cancellationToken, out var result)) { continue; } @@ -97,11 +102,11 @@ protected bool TryValidate([NotNullWhen(true)] object? input, IList(Action binder) where T : notnull { + ArgumentNullException.ThrowIfNull(binder); + + Register((model, _) => binder(model)); + } + + public static void Register(Action binder) where T : notnull + { + ArgumentNullException.ThrowIfNull(binder); + s_binders[typeof(T)] = binder; } - internal static bool TryGetBinder(out Action? binder) where T : notnull + internal static bool TryGetBinder(out Action? binder) where T : notnull { if (s_binders.TryGetValue(typeof(T), out var obj)) { - binder = (Action)obj; + binder = (Action)obj; return true; } diff --git a/src/Sharprompt/Prompt.Basic.cs b/src/Sharprompt/Prompt.Basic.cs index 05d2d5f..141e225 100644 --- a/src/Sharprompt/Prompt.Basic.cs +++ b/src/Sharprompt/Prompt.Basic.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Threading; using Sharprompt.Forms; using Sharprompt.Internal; @@ -10,19 +11,29 @@ namespace Sharprompt; public static partial class Prompt { public static T Input(InputOptions options) + { + return Input(options, CancellationToken.None); + } + + public static T Input(InputOptions options, CancellationToken cancellationToken) { using var form = new InputForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static T Input(Action> configure) + { + return Input(configure, CancellationToken.None); + } + + public static T Input(Action> configure, CancellationToken cancellationToken) { var options = new InputOptions(); configure(options); - return Input(options); + return Input(options, cancellationToken); } public static T Input(string message, object? defaultValue = default, string? placeholder = default, IList>? validators = default) @@ -38,19 +49,29 @@ public static T Input(string message, object? defaultValue = default, string? } public static string Password(PasswordOptions options) + { + return Password(options, CancellationToken.None); + } + + public static string Password(PasswordOptions options, CancellationToken cancellationToken) { using var form = new PasswordForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static string Password(Action configure) + { + return Password(configure, CancellationToken.None); + } + + public static string Password(Action configure, CancellationToken cancellationToken) { var options = new PasswordOptions(); configure(options); - return Password(options); + return Password(options, cancellationToken); } public static string Password(string message, string passwordChar = "*", string? placeholder = default, IList>? validators = default) @@ -66,19 +87,29 @@ public static string Password(string message, string passwordChar = "*", string? } public static bool Confirm(ConfirmOptions options) + { + return Confirm(options, CancellationToken.None); + } + + public static bool Confirm(ConfirmOptions options, CancellationToken cancellationToken) { using var form = new ConfirmForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static bool Confirm(Action configure) + { + return Confirm(configure, CancellationToken.None); + } + + public static bool Confirm(Action configure, CancellationToken cancellationToken) { var options = new ConfirmOptions(); configure(options); - return Confirm(options); + return Confirm(options, cancellationToken); } public static bool Confirm(string message, bool? defaultValue = default) @@ -91,19 +122,29 @@ public static bool Confirm(string message, bool? defaultValue = default) } public static T Select(SelectOptions options) where T : notnull + { + return Select(options, CancellationToken.None); + } + + public static T Select(SelectOptions options, CancellationToken cancellationToken) where T : notnull { using var form = new SelectForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static T Select(Action> configure) where T : notnull + { + return Select(configure, CancellationToken.None); + } + + public static T Select(Action> configure, CancellationToken cancellationToken) where T : notnull { var options = new SelectOptions(); configure(options); - return Select(options); + return Select(options, cancellationToken); } public static T Select(string message, IEnumerable? items = default, int pageSize = int.MaxValue, object? defaultValue = default, Func? textSelector = default) where T : notnull @@ -128,19 +169,29 @@ public static T Select(string message, IEnumerable? items = default, int p } public static IEnumerable MultiSelect(MultiSelectOptions options) where T : notnull + { + return MultiSelect(options, CancellationToken.None); + } + + public static IEnumerable MultiSelect(MultiSelectOptions options, CancellationToken cancellationToken) where T : notnull { using var form = new MultiSelectForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static IEnumerable MultiSelect(Action> configure) where T : notnull + { + return MultiSelect(configure, CancellationToken.None); + } + + public static IEnumerable MultiSelect(Action> configure, CancellationToken cancellationToken) where T : notnull { var options = new MultiSelectOptions(); configure(options); - return MultiSelect(options); + return MultiSelect(options, cancellationToken); } public static IEnumerable MultiSelect(string message, IEnumerable? items = null, int pageSize = int.MaxValue, int minimum = 1, int maximum = int.MaxValue, IEnumerable? defaultValues = default, Func? textSelector = default) where T : notnull @@ -171,19 +222,29 @@ public static IEnumerable MultiSelect(string message, IEnumerable? item } public static IEnumerable List(ListOptions options) where T : notnull + { + return List(options, CancellationToken.None); + } + + public static IEnumerable List(ListOptions options, CancellationToken cancellationToken) where T : notnull { using var form = new ListForm(options, s_configuration); - return form.Start(); + return form.Start(cancellationToken); } public static IEnumerable List(Action> configure) where T : notnull + { + return List(configure, CancellationToken.None); + } + + public static IEnumerable List(Action> configure, CancellationToken cancellationToken) where T : notnull { var options = new ListOptions(); configure(options); - return List(options); + return List(options, cancellationToken); } public static IEnumerable List(string message, int minimum = 1, int maximum = int.MaxValue, IList>? validators = default) where T : notnull diff --git a/src/Sharprompt/Prompt.Bind.cs b/src/Sharprompt/Prompt.Bind.cs index 20ee0b3..b837762 100644 --- a/src/Sharprompt/Prompt.Bind.cs +++ b/src/Sharprompt/Prompt.Bind.cs @@ -1,24 +1,35 @@ using System; +using System.Threading; namespace Sharprompt; public static partial class Prompt { public static T Bind() where T : notnull, new() + { + return Bind(CancellationToken.None); + } + + public static T Bind(CancellationToken cancellationToken) where T : notnull, new() { var model = new T(); - return Bind(model); + return Bind(model, cancellationToken); } public static T Bind(T model) where T : notnull + { + return Bind(model, CancellationToken.None); + } + + public static T Bind(T model, CancellationToken cancellationToken) where T : notnull { if (!ModelBinderRegistry.TryGetBinder(out var binder)) { throw new InvalidOperationException($"No SourceGenerator-based binder is registered for type '{typeof(T).FullName}'. Apply [PromptBindable] attribute to the type."); } - binder!(model); + binder!(model, cancellationToken); return model; } diff --git a/tests/Sharprompt.Tests/Binding/ModelBinderRegistryTests.cs b/tests/Sharprompt.Tests/Binding/ModelBinderRegistryTests.cs index 2986fcd..a631515 100644 --- a/tests/Sharprompt.Tests/Binding/ModelBinderRegistryTests.cs +++ b/tests/Sharprompt.Tests/Binding/ModelBinderRegistryTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using Xunit; @@ -9,16 +10,56 @@ public class ModelBinderRegistryTests [Fact] public void Register_And_TryGetBinder_ReturnsRegisteredBinder() { - Action binder = _ => { }; + TestModel? boundModel = null; - ModelBinderRegistry.Register(binder); + ModelBinderRegistry.Register(model => boundModel = model); var found = ModelBinderRegistry.TryGetBinder(out var retrievedBinder); + Assert.True(found); + + var model = new TestModel(); + + retrievedBinder!(model, CancellationToken.None); + + Assert.Same(model, boundModel); + } + + [Fact] + public void Register_WithCancellationToken_ReturnsRegisteredBinder() + { + Action binder = (_, _) => { }; + + ModelBinderRegistry.Register(binder); + + var found = ModelBinderRegistry.TryGetBinder(out var retrievedBinder); + Assert.True(found); Assert.Same(binder, retrievedBinder); } + [Fact] + public void Register_NullBinder_Throws() + { + Assert.Throws(() => ModelBinderRegistry.Register((Action)null!)); + Assert.Throws(() => ModelBinderRegistry.Register((Action)null!)); + } + + [Fact] + public void Bind_PassesCancellationTokenToBinder() + { + CancellationToken boundToken = default; + + ModelBinderRegistry.Register((_, cancellationToken) => boundToken = cancellationToken); + + using var cts = new CancellationTokenSource(); + + var model = new TokenCapturingModel(); + + Assert.Same(model, Prompt.Bind(model, cts.Token)); + Assert.Equal(cts.Token, boundToken); + } + [Fact] public void TryGetBinder_Unregistered_ReturnsFalse() { @@ -30,5 +71,9 @@ public void TryGetBinder_Unregistered_ReturnsFalse() public class TestModel; + public class CancelableTestModel; + + public class TokenCapturingModel; + public class UnregisteredModel; } diff --git a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs index 88a808a..f1308ff 100644 --- a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs +++ b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Threading; using Sharprompt.Forms; @@ -330,6 +331,59 @@ public void Escape_ThrowsPromptCanceledException() Assert.Throws(() => form.Start()); } + [Fact] + public void CanceledToken_ThrowsBeforeRendering() + { + var (driver, configuration) = CreateTestContext(); + + driver.EnqueueText("abc"); + driver.EnqueueEnter(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + using var form = new InputForm(new InputOptions { Message = "message" }, configuration); + + var exception = Assert.Throws(() => form.Start(cts.Token)); + + Assert.Equal(cts.Token, exception.CancellationToken); + Assert.Empty(driver.Output); + Assert.True(driver.KeyAvailable); + } + + [Fact] + public void TokenCanceledWhileWaitingForInput_ThrowsOperationCanceledException() + { + var (driver, configuration) = CreateTestContext(); + + driver.EnqueueText("abc"); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + using var form = new InputForm(new InputOptions { Message = "message" }, configuration); + + var exception = Assert.Throws(() => form.Start(cts.Token)); + + Assert.Equal(cts.Token, exception.CancellationToken); + Assert.False(driver.KeyAvailable); + } + + [Fact] + public void CancelableTokenNotCanceled_ReturnsResult() + { + var (driver, configuration) = CreateTestContext(); + + driver.EnqueueText("abc"); + driver.EnqueueEnter(); + + using var cts = new CancellationTokenSource(); + + using var form = new InputForm(new InputOptions { Message = "message" }, configuration); + + Assert.Equal("abc", form.Start(cts.Token)); + } + [Fact] public void SelectForm_MessageLongerThanBufferWidth_StillWorks() { diff --git a/tests/Sharprompt.Tests/Sharprompt.Tests.csproj b/tests/Sharprompt.Tests/Sharprompt.Tests.csproj index b8698ba..9d1f122 100644 --- a/tests/Sharprompt.Tests/Sharprompt.Tests.csproj +++ b/tests/Sharprompt.Tests/Sharprompt.Tests.csproj @@ -3,6 +3,8 @@ net8.0;net10.0 false + + $(NoWarn);xUnit1051 From 82cacc789e9862cbd2f4ef7b301d428e42db3247 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 30 Sep 2026 13:40:17 +0900 Subject: [PATCH 2/2] Make token cancellation test deterministic Cancel the token from FakeConsoleDriver once every queued key has been consumed instead of relying on a wall-clock delay. Co-Authored-By: Claude Opus 5.5 --- .../Forms/FakeConsoleDriver.cs | 19 ++++++++++++++++++- .../Forms/FormInteractionTests.cs | 5 ++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/Sharprompt.Tests/Forms/FakeConsoleDriver.cs b/tests/Sharprompt.Tests/Forms/FakeConsoleDriver.cs index c060425..239b17c 100644 --- a/tests/Sharprompt.Tests/Forms/FakeConsoleDriver.cs +++ b/tests/Sharprompt.Tests/Forms/FakeConsoleDriver.cs @@ -83,7 +83,24 @@ public void SetCursorPosition(int left, int top) _cursorTop = top; } - public bool KeyAvailable => _keys.Count > 0; + // Invoked when the form polls for input after every queued key has been consumed, + // giving tests a deterministic point to react to the form waiting for more input. + public Action? KeysExhausted { get; set; } + + public bool KeyAvailable + { + get + { + if (_keys.Count > 0) + { + return true; + } + + KeysExhausted?.Invoke(); + + return false; + } + } public bool CursorVisible { get; set; } = true; diff --git a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs index f1308ff..8ff98e7 100644 --- a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs +++ b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs @@ -359,7 +359,9 @@ public void TokenCanceledWhileWaitingForInput_ThrowsOperationCanceledException() driver.EnqueueText("abc"); using var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + // Cancel only once every queued key has been consumed and the form is waiting for more input. + driver.KeysExhausted = cts.Cancel; using var form = new InputForm(new InputOptions { Message = "message" }, configuration); @@ -367,6 +369,7 @@ public void TokenCanceledWhileWaitingForInput_ThrowsOperationCanceledException() Assert.Equal(cts.Token, exception.CancellationToken); Assert.False(driver.KeyAvailable); + Assert.Contains("abc", driver.Output); } [Fact]