From 2e5e5eba822a110326c1845e3a27a3c9cd2827a7 Mon Sep 17 00:00:00 2001 From: Fernando Cerqueira Date: Sat, 25 Jul 2026 16:00:25 -0300 Subject: [PATCH 1/8] Add missing AdrPlus repository config docs/adr/adr-config.adrplus was absent, even though the existing ADR files clearly follow the AdrPlus Nygard-template/profile-3 convention (header fields, disclaimer text, and naming all match its defaults) and PromptPlus's sibling repo already has this file. Without it, the adrplus CLI has no way to manage this repo's ADRs. --- docs/adr/adr-config.adrplus | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/adr/adr-config.adrplus diff --git a/docs/adr/adr-config.adrplus b/docs/adr/adr-config.adrplus new file mode 100644 index 0000000..f09c9e6 --- /dev/null +++ b/docs/adr/adr-config.adrplus @@ -0,0 +1,31 @@ +{ + "folderadr": "docs/adr", + "migrationpattern": "", + "template": "", + "prefix": "ADR", + "lenseq": 4, + "lenversion": 2, + "lenrevision": 2, + "lenscope": 0, + "separator": "-", + "casetransform": "PascalCase", + "statusnew": "Proposed", + "statusacc": "Accepted", + "statusrej": "Rejected", + "statussup": "Superseded", + "scopes": "", + "folderbyscope": false, + "skipdomain": "", + "headerdisclaimer": "Do not remove this comment, lines and table", + "headertitlefile": "ADR", + "headerversion": "Version", + "headerrevision": "Revision", + "headerscope": "Scope", + "headerdomain": "Domain", + "headertitlestatuscreated": "Created", + "headertitlestatuschanged": "Changed", + "headertitlestatussuperseded": "Superseded", + "headertablefields": "Fields", + "headertablevalues": "Values", + "headermigrated": "Migrated" +} From 7f1f15b5135c33a2b9ca8ca78fbed206d77c4963 Mon Sep 17 00:00:00 2001 From: Fernando Cerqueira Date: Sat, 25 Jul 2026 16:06:47 -0300 Subject: [PATCH 2/8] Bump version to 1.0.0-Beta2 in ConsolePlus.csproj Updated the package version in ConsolePlus.csproj from 1.0.0-Beta1 to 1.0.0-Beta2 to prepare for the next beta release. --- src/ConsolePlus.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConsolePlus.csproj b/src/ConsolePlus.csproj index 1b129bd..f2d8283 100644 --- a/src/ConsolePlus.csproj +++ b/src/ConsolePlus.csproj @@ -24,7 +24,7 @@ ConsolePlus.net README.md https://github.com/FRACerqueira/ConsolePlus - 1.0.0-Beta1 + 1.0.0-Beta2 icon.png © 2026 - Fernando Cerqueira True From d7151c80c41b820cfedfcb32edd3ad5128b81c0c Mon Sep 17 00:00:00 2001 From: Fernando Cerqueira Date: Sat, 25 Jul 2026 16:31:01 -0300 Subject: [PATCH 3/8] Merge ADR0011 into ADR0001 as revision R02 Both records documented the same ConsolePlus/PromptPlus split decision; ADR0011 restated it without adding a distinct decision, so it is folded into ADR0001V01R02 per the project's revision-over-new-number policy. Fixes cross-links in the ADR index and neighboring ADRs' nav. Co-Authored-By: Claude Sonnet 5 --- ...01V01R02-SplitConsolePlusAndPromptPlus.md} | 26 ++++++---- ...DR0002V01R02-ImmutableCapabilityProfile.md | 2 +- docs/adr/ADR0010V01R01-MarkupLanguage.md | 2 +- .../ADR0011V01R01-SeparationFromPromptPlus.md | 51 ------------------- .../ADR0012V01R01-ShutdownStateRestoration.md | 2 +- docs/adr/README.md | 5 +- 6 files changed, 21 insertions(+), 67 deletions(-) rename docs/adr/{ADR0001V01R01-SplitConsolePlusAndPromptPlus.md => ADR0001V01R02-SplitConsolePlusAndPromptPlus.md} (75%) delete mode 100644 docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md diff --git a/docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md b/docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md similarity index 75% rename from docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md rename to docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md index 6816a7c..4635ac7 100644 --- a/docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md +++ b/docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md @@ -1,20 +1,20 @@ - +
ConsolePlus - # ADR0001V01R01 + # ADR0001V01R02
[ADR Index](README.md) • **Next:** [ADR0002 →](ADR0002V01R02-ImmutableCapabilityProfile.md) @@ -23,11 +23,12 @@ -# ADR0001V01R01 — Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) +# ADR0001V01R02 — Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) - **Status:** Accepted -- **Version:** V01 / Revision R01 +- **Version:** V01 / Revision R02 - **Created:** 2026-07-24 +- **Changed:** 2026-07-25 ## Context @@ -60,6 +61,12 @@ ConsolePlus is the base layer and has **no dependency on PromptPlus**. PromptPlu depends on ConsolePlus (one-directional) and reuses the same console driver instance (`PromptPlus.Console` is the same as `ConsolePlus.Driver`). +**R02 — 2026-07-25:** this record absorbs the former ADR0011 ("Separation of +ConsolePlus and PromptPlus"), which restated the same split decision from the +ongoing-boundary angle without adding a distinct decision of its own. The +boundary must be kept clean going forward: neither project should leak +implementation details across the dependency direction established above. + ## Consequences - **Positive:** ConsolePlus can be consumed standalone for pure rendering; the @@ -67,10 +74,9 @@ instance (`PromptPlus.Console` is the same as `ConsolePlus.Driver`). rendering/interaction boundary is explicit and independently testable. - **Negative / trade-off:** two repositories to coordinate when the shared driver contract changes; PromptPlus consumers upgrading from 5.x must take on the - ConsolePlus dependency. + ConsolePlus dependency; the boundary must be actively kept clean to avoid + leakage in either direction. ## Related -- [ADR0011](ADR0011V01R01-SeparationFromPromptPlus.md) details the ongoing - boundary and one-directional dependency between the two projects. - PromptPlus ADR0001 records the same decision from the interaction side. diff --git a/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md b/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md index e5f7153..f948997 100644 --- a/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md +++ b/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md @@ -17,7 +17,7 @@ # ADR0002V01R02 -[← ADR0001](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) • [ADR Index](README.md) • **Next:** [ADR0003 →](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) +[← ADR0001](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) • [ADR Index](README.md) • **Next:** [ADR0003 →](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) --- diff --git a/docs/adr/ADR0010V01R01-MarkupLanguage.md b/docs/adr/ADR0010V01R01-MarkupLanguage.md index a03c484..809e55d 100644 --- a/docs/adr/ADR0010V01R01-MarkupLanguage.md +++ b/docs/adr/ADR0010V01R01-MarkupLanguage.md @@ -17,7 +17,7 @@ # ADR0010V01R01 -[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0011 →](ADR0011V01R01-SeparationFromPromptPlus.md) +[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0012 →](ADR0012V01R01-ShutdownStateRestoration.md) --- diff --git a/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md b/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md deleted file mode 100644 index f8c5682..0000000 --- a/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md +++ /dev/null @@ -1,51 +0,0 @@ - - - -
- ConsolePlus - - # ADR0011V01R01 -
- -[← ADR0010](ADR0010V01R01-MarkupLanguage.md) • [ADR Index](README.md) • **Next:** [ADR0012 →](ADR0012V01R01-ShutdownStateRestoration.md) - ---- - - - -# ADR0011V01R01 — Separation of ConsolePlus and PromptPlus - -- **Status:** Accepted -- **Version:** V01 / Revision R01 -- **Created:** 2026-07-24 - -## Context - -Rendering (writing styled output, managing the screen) and interaction -(prompts, controls) are distinct concerns. Combining them in one library would -force interaction dependencies on users who only need rendering. - -## Decision - -Keep two separate products in two repositories: **ConsolePlus is the rendering -foundation** ("how you render") and **PromptPlus is the interactive toolkit** -("how you interact") layered on top. PromptPlus reuses the same console driver — -`PromptPlus.Console` is the same instance as `ConsolePlus.Driver`. ConsolePlus -has no dependency on PromptPlus. - -## Consequences - -- **Positive:** rendering can be used standalone; clear one-directional - dependency; each product versions independently. -- **Negative / trade-off:** cross-repo coordination is needed when the shared - driver contract changes; the boundary must be kept clean to avoid leakage. diff --git a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md b/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md index de49264..4ca31ec 100644 --- a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md +++ b/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md @@ -17,7 +17,7 @@ # ADR0012V01R01 -[← ADR0011](ADR0011V01R01-SeparationFromPromptPlus.md) • [ADR Index](README.md) • **Next:** [ADR0013 →](ADR0013V01R01-EmojiShortcodeModel.md) +[← ADR0010](ADR0010V01R01-MarkupLanguage.md) • [ADR Index](README.md) • **Next:** [ADR0013 →](ADR0013V01R01-EmojiShortcodeModel.md) --- diff --git a/docs/adr/README.md b/docs/adr/README.md index 9513187..36cd068 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,7 +4,7 @@ # Architecture Decision Records (ADR) -[Back to Home](../../README.md) • **First:** [ADR0001 →](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) +[Back to Home](../../README.md) • **First:** [ADR0001 →](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) --- @@ -45,7 +45,7 @@ projects (ConsolePlus + PromptPlus). | ADR | Title | Version | Status | | --- | --- | --- | --- | -| [ADR0001V01R01](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) | Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) | V01 | Accepted | +| [ADR0001V01R02](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) | Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) | V01 (R02) | Accepted | | [ADR0002V01R02](ADR0002V01R02-ImmutableCapabilityProfile.md) | Immutable capability profile | V01 (R02) | Accepted | | [ADR0003V01R01](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) | Automatic initialization on first access | V01 | Accepted | | [ADR0004V01R01](ADR0004V01R01-AnsiVsNonAnsiDriverSelection.md) | ANSI vs non-ANSI driver selection | V01 | Accepted | @@ -55,7 +55,6 @@ projects (ConsolePlus + PromptPlus). | [ADR0008V01R01](ADR0008V01R01-ColorDepthModel.md) | Color depth model (`ColorSystem`) | V01 | Accepted | | [ADR0009V01R01](ADR0009V01R01-StyleModelColorOnly.md) | Style model: color + overflow only | V01 | Accepted | | [ADR0010V01R01](ADR0010V01R01-MarkupLanguage.md) | Inline markup language for styling | V01 | Accepted | -| [ADR0011V01R01](ADR0011V01R01-SeparationFromPromptPlus.md) | Separation of ConsolePlus and PromptPlus | V01 | Accepted | | [ADR0012V01R01](ADR0012V01R01-ShutdownStateRestoration.md) | Shutdown state restoration | V01 | Accepted | | [ADR0013V01R01](ADR0013V01R01-EmojiShortcodeModel.md) | Emoji shortcode model with fallback | V01 | Accepted | | [ADR0014V01R01](ADR0014V01R01-LowLevelAnsiAndAlternateScreen.md) | Low-level ANSI and alternate-screen API | V01 | Accepted | From b5a3c76a6866951a064c39e30f1e7323e1005005 Mon Sep 17 00:00:00 2001 From: Fernando Cerqueira Date: Sat, 25 Jul 2026 16:35:23 -0300 Subject: [PATCH 4/8] Renumber ADR0012-0015 to close the gap left by the ADR0011 merge ADR0011 was folded into ADR0001V01R02 in the previous commit, leaving a gap in the sequence. Shift ShutdownStateRestoration, EmojiShortcodeModel, LowLevelAnsiAndAlternateScreen, and GeneratedApiDocsOffLimits down by one (0012->0011, 0013->0012, 0014->0013, 0015->0014) so the ADR sequence is contiguous again, fixing every cross-link accordingly. Co-Authored-By: Claude Sonnet 5 --- docs/adr/ADR0010V01R01-MarkupLanguage.md | 2 +- ...d => ADR0011V01R01-ShutdownStateRestoration.md} | 14 +++++++------- ...del.md => ADR0012V01R01-EmojiShortcodeModel.md} | 14 +++++++------- ...DR0013V01R01-LowLevelAnsiAndAlternateScreen.md} | 14 +++++++------- ... => ADR0014V01R02-GeneratedApiDocsOffLimits.md} | 14 +++++++------- docs/adr/README.md | 8 ++++---- 6 files changed, 33 insertions(+), 33 deletions(-) rename docs/adr/{ADR0012V01R01-ShutdownStateRestoration.md => ADR0011V01R01-ShutdownStateRestoration.md} (83%) rename docs/adr/{ADR0013V01R01-EmojiShortcodeModel.md => ADR0012V01R01-EmojiShortcodeModel.md} (78%) rename docs/adr/{ADR0014V01R01-LowLevelAnsiAndAlternateScreen.md => ADR0013V01R01-LowLevelAnsiAndAlternateScreen.md} (78%) rename docs/adr/{ADR0015V01R02-GeneratedApiDocsOffLimits.md => ADR0014V01R02-GeneratedApiDocsOffLimits.md} (92%) diff --git a/docs/adr/ADR0010V01R01-MarkupLanguage.md b/docs/adr/ADR0010V01R01-MarkupLanguage.md index 809e55d..c1169c0 100644 --- a/docs/adr/ADR0010V01R01-MarkupLanguage.md +++ b/docs/adr/ADR0010V01R01-MarkupLanguage.md @@ -17,7 +17,7 @@ # ADR0010V01R01 -[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0012 →](ADR0012V01R01-ShutdownStateRestoration.md) +[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0011 →](ADR0011V01R01-ShutdownStateRestoration.md) --- diff --git a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md b/docs/adr/ADR0011V01R01-ShutdownStateRestoration.md similarity index 83% rename from docs/adr/ADR0012V01R01-ShutdownStateRestoration.md rename to docs/adr/ADR0011V01R01-ShutdownStateRestoration.md index 4ca31ec..b26608c 100644 --- a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md +++ b/docs/adr/ADR0011V01R01-ShutdownStateRestoration.md @@ -1,8 +1,8 @@ - - + - + - + - + + + +
+ ConsolePlus + + # ADR0015V01R01 +
+ +[← ADR0014](ADR0014V01R02-GeneratedApiDocsOffLimits.md) • [ADR Index](README.md) + +--- + +# ADR0015V01R01 — Redirected/headless console I/O: fail-safe writes, fail-loud reads + +- **Status:** Accepted +- **Version:** V01 / Revision R01 +- **Created:** 2026-07-28 + +## Context + +A process whose console I/O is redirected (a file, a pipe, a CI runner, or a test host such as +`dotnet test` itself) has no live OS console handle. Two classes of member are affected differently, +and two real bugs (found 2026-07-28 while auditing whether ConsolePlus's public API runs error-free +under redirection) showed the driver was inconsistent about which failure mode applies where: + +1. **Cursor/screen writes** — `Clear()`, `SetCursorPosition()`, the `CursorLeft`/`CursorTop` setters — + called the raw `Console.*` API unguarded in `NoAnsiConsoleAdapter`. Under a redirected/headless + process this threw `IOException: "The handle is invalid."`, even though `HideCursor()`/ + `ShowCursor()` and `EnvironmentUtil.GetSafeWidth`/`GetSafeHeight`/`GetSafeTopCursor` already had an + established "Safe" pattern (catch `IOException`/`PlatformNotSupportedException`, degrade quietly) + for exactly this situation. +2. **Key reads** — `KeyAvailable`, `ReadKey`, `ReadKeyAsync` — only guarded on `_profile.Interactive`, + which reflects a **hardcoded list of known CI providers** (see + [Environment Detection](../environment-detection.md)), not the real `Console.IsInputRedirected`. + Any redirected process outside that list (including this project's own local `dotnet test` run) + hit the raw `Console.KeyAvailable`/`ReadKey` call, which throws a raw, undocumented + `InvalidOperationException` for `ReadKey` or behaves unpredictably for `KeyAvailable`. + +`Read()`/`ReadLine()` were not part of this problem: they read from the input **text stream**, which +a redirected file/pipe legitimately provides — `Console.ReadLine()` works correctly against redirected +input. Only key-based reads need a **live interactive input buffer**, which redirection does not have. + +## Decision + +Establish one explicit, documented contract for every member that touches the console under +redirected/headless I/O, split by what the member does: + +- **Presentation-only writes** (`Clear`, `SetCursorPosition`, `CursorLeft`/`CursorTop` setters, + `HideCursor`/`ShowCursor`) — **fail safe**. Wrap the raw `Console.*` call in + `catch (Exception ex) when (ex is PlatformNotSupportedException or IOException)` and silently + no-op (or return `false` for the two members that already have a boolean success contract). These + calls only affect what is visually drawn; there is nothing meaningful to report when there is no + screen to draw on, and forcing every caller to guard every cursor/clear call would be unreasonable. +- **Blocking/consuming reads** (`Read`, `ReadLine`, `ReadKey`, `ReadKeyAsync`) — **fail loud, predictably**. + Throw `InvalidOperationException("Console is not interactive.")` — the same message and type for + all four — instead of letting a raw platform exception (or an inconsistent one) leak through. A + read that cannot happen is a real logic error the caller must handle, not something to swallow. +- **The non-blocking probe** `KeyAvailable` — **fails safe**, returning `false` instead of throwing. + It already has a non-throwing contract (`while (!KeyAvailable) { ... }` is the documented polling + idiom in [Reading Input](../reading-input.md)); making it throw would break every such loop. +- `ReadKey`/`ReadKeyAsync`/`KeyAvailable` check **both** `_profile.Interactive` **and** + `Console.IsInputRedirected` explicitly — `Interactive` alone only reflects the CI-provider + heuristic and does not catch plain redirection outside that list. `Read()`/`ReadLine()` keep + checking `Interactive` only, since redirected input is a valid source for them. + +## Consequences + +- **Positive:** one predictable rule instead of case-by-case behavior — "does this member draw, or + does it consume a real keystroke?" answers which failure mode applies. No raw platform exceptions + (`IOException`, undocumented `InvalidOperationException` text) leak to callers. Matches the + project's existing "Safe" pattern (`EnvironmentUtil.GetSafe*`) instead of adding a second one. +- **Negative / trade-off:** a caller polling `KeyAvailable` in a loop with no independent exit + condition (no `CancellationToken`, no timeout) will now loop forever under redirected input instead + of crashing, because `KeyAvailable` no longer throws — it simply never becomes `true`. This is + correct for `KeyAvailable`'s own non-throwing contract, but any code built on top of it (such as + PromptPlus's interactive controls) must add its **own** upfront `IsInputRedirected` check if it + wants to fail fast instead of hanging — see PromptPlus's + [ADR0023 — Guard interactive controls against redirected console input](https://github.com/FRACerqueira/PromptPlus/blob/develop/docs/adr/ADR0023V01R01-GuardInteractiveControlsAgainstRedirectedInput.md), + which does exactly that on top of this contract. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1512172..81aed65 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -59,3 +59,4 @@ projects (ConsolePlus + PromptPlus). | [ADR0012V01R01](ADR0012V01R01-EmojiShortcodeModel.md) | Emoji shortcode model with fallback | V01 | Accepted | | [ADR0013V01R01](ADR0013V01R01-LowLevelAnsiAndAlternateScreen.md) | Low-level ANSI and alternate-screen API | V01 | Accepted | | [ADR0014V01R02](ADR0014V01R02-GeneratedApiDocsOffLimits.md) | Generated API docs are off-limits for manual edits; regeneration gated on `ReleaseDoc` | V01 (R02) | Accepted | +| [ADR0015V01R01](ADR0015V01R01-RedirectedConsoleIoContract.md) | Redirected/headless console I/O: fail-safe writes, fail-loud reads | V01 | Accepted | diff --git a/docs/cursor-and-screen.md b/docs/cursor-and-screen.md index b599236..f3fc692 100644 --- a/docs/cursor-and-screen.md +++ b/docs/cursor-and-screen.md @@ -82,6 +82,13 @@ ConsolePlus.Clear(ConsoleColor.Blue); // ConsoleColor implicitly converts to C `Clear(Color?)` fills the buffer and sets the background color in one step. See also [`ClearLine`](writing-output.md#blank-lines-and-clearing-lines) to clear a single row. +> ℹ️ **Redirected or headless console.** `Clear()`, `SetCursorPosition()`, the `CursorLeft`/`CursorTop` +> setters, and `HideCursor()`/`ShowCursor()` never throw when there is no real console attached (output +> redirected to a file/pipe, or a headless/CI process) — they silently do nothing (the two `bool`-returning +> members return `false`). These are presentation-only operations with nothing meaningful to report when +> there is no screen to draw on. See [ADR0015](adr/ADR0015V01R01-RedirectedConsoleIoContract.md) for the +> full redirected-I/O contract, including how key-based **reads** differ (they throw predictably instead). + --- ## Window size and resize events diff --git a/docs/reading-input.md b/docs/reading-input.md index 829b37c..674197f 100644 --- a/docs/reading-input.md +++ b/docs/reading-input.md @@ -258,10 +258,16 @@ while (!ConsolePlus.KeyAvailable) var key = ConsolePlus.ReadKey(intercept: true); ``` -> ⚠️ `ReadKey`/`ReadKeyAsync` throw `InvalidOperationException` ("Console is not interactive.") -> when `ConsolePlus.Profile.Interactive` is `false` — for example, when input is redirected. Check -> `IsInputRedirected` (or `Profile.Interactive`) before calling them in code that might run with -> redirected/piped input. +> ⚠️ **Redirected or non-interactive input.** `ReadKey`/`ReadKeyAsync` need a live keyboard input +> buffer, which a redirected input (file/pipe) does not have. They throw `InvalidOperationException` +> ("Console is not interactive.") when **either** `Profile.Interactive` is `false` **or** +> `IsInputRedirected` is `true` — check `IsInputRedirected` explicitly before calling them, since +> `Profile.Interactive` alone only reflects a hardcoded list of known CI providers (see +> [Environment Detection](environment-detection.md)) and misses plain redirection outside that list. +> `KeyAvailable` never throws under either condition — it simply returns `false`, so the polling loop +> above is always safe to use as written. `Read()`/`ReadLine()` are unaffected by `IsInputRedirected` +> (a redirected stream is a legitimate source for them); they only throw when `Profile.Interactive` +> is `false`. See [ADR0015](adr/ADR0015V01R01-RedirectedConsoleIoContract.md) for the full contract. ---