Skip to content

Commit 794f4e8

Browse files
committed
fix(chat): stop background stdin reader from breaking interactive pickers
The steering feature spawned a persistent background thread blocked in Console.ReadLine, which competed with Spectre interactive prompts (/models, /tree, trust) for console input: arrow keys and typed lines were split between two readers, leaving pickers stuck and echoing stolen input. Remove the reader entirely. Idle input is plain blocking Console.ReadLine again, and in-turn steering now polls Console.KeyAvailable with a small delay, echoing keystrokes and handling Enter/Backspace locally, so nothing ever contends with a picker. When stdin is redirected the turn is simply awaited (steering needs a console). Multi-line blocks read synchronously.
1 parent 0f194e3 commit 794f4e8

1 file changed

Lines changed: 77 additions & 43 deletions

File tree

‎src/WinHarness.Cli/Program.cs‎

Lines changed: 77 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,25 +1394,11 @@ public static async ValueTask RunAsync(
13941394
services.GetRequiredService<IAgentRuntime>(),
13951395
cancellationToken);
13961396

1397-
// Persistent background stdin reader so the user can type while a turn
1398-
// is running (steering). Lines are consumed from the channel both by
1399-
// the idle prompt loop and by the in-turn steering listener.
1400-
System.Threading.Channels.Channel<string?> stdin =
1401-
System.Threading.Channels.Channel.CreateUnbounded<string?>();
1402-
_ = Task.Run(
1403-
() =>
1404-
{
1405-
while (true)
1406-
{
1407-
string? line = Console.ReadLine();
1408-
if (!stdin.Writer.TryWrite(line) || line is null)
1409-
{
1410-
return;
1411-
}
1412-
}
1413-
},
1414-
CancellationToken.None);
1415-
1397+
// Idle input uses plain blocking Console.ReadLine so interactive
1398+
// pickers (Spectre prompts in /models, /tree, trust) own the console
1399+
// exclusively. Steering input during a turn is read via
1400+
// Console.KeyAvailable polling inside RunTurnWithSteeringAsync — never
1401+
// from a competing background reader.
14161402
Queue<string> followUps = new();
14171403

14181404
while (!cancellationToken.IsCancellationRequested)
@@ -1426,7 +1412,7 @@ public static async ValueTask RunAsync(
14261412
else
14271413
{
14281414
AnsiConsole.Markup("[bold green]›[/] ");
1429-
input = await stdin.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
1415+
input = Console.ReadLine();
14301416
}
14311417

14321418
if (input is null)
@@ -1471,7 +1457,7 @@ public static async ValueTask RunAsync(
14711457
{
14721458
case EditorInputKind.MultiLineStart:
14731459
{
1474-
string? block = await ReadMultiLineBlockAsync(input, stdin.Reader, cancellationToken).ConfigureAwait(false);
1460+
string? block = ReadMultiLineBlock(input);
14751461
if (block is null)
14761462
{
14771463
return;
@@ -1520,7 +1506,6 @@ await RunTurnWithSteeringAsync(
15201506
services,
15211507
session,
15221508
input,
1523-
stdin.Reader,
15241509
followUps,
15251510
cancellationToken).ConfigureAwait(false);
15261511

@@ -1536,40 +1521,42 @@ await RunTurnWithSteeringAsync(
15361521
}
15371522

15381523
/// <summary>
1539-
/// Runs a turn on a background task while listening for typed input:
1524+
/// Runs a turn on a background task while polling for typed input:
15401525
/// plain lines queue steering (delivered between tool round-trips),
15411526
/// "&gt;&gt; text" queues a follow-up turn, and /abort cancels the turn.
1527+
/// Uses Console.KeyAvailable polling, never a background reader, so
1528+
/// interactive pickers outside turns own the console exclusively. When
1529+
/// input is redirected, in-turn steering is unavailable and the turn is
1530+
/// simply awaited.
15421531
/// </summary>
15431532
private static async ValueTask RunTurnWithSteeringAsync(
15441533
IServiceProvider services,
15451534
ChatSession session,
15461535
string prompt,
1547-
System.Threading.Channels.ChannelReader<string?> stdin,
15481536
Queue<string> followUps,
15491537
CancellationToken cancellationToken)
15501538
{
15511539
using CancellationTokenSource turnCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
15521540
Task turn = RunTurnAsync(services, session, prompt, turnCts.Token).AsTask();
15531541

1542+
if (Console.IsInputRedirected)
1543+
{
1544+
await AwaitTurnAsync(turn, cancellationToken).ConfigureAwait(false);
1545+
return;
1546+
}
1547+
1548+
StringBuilder pending = new();
15541549
while (!turn.IsCompleted)
15551550
{
1556-
Task<string?> readTask = stdin.ReadAsync(CancellationToken.None).AsTask();
1557-
Task finished = await Task.WhenAny(turn, readTask).ConfigureAwait(false);
1558-
if (finished == turn)
1551+
string? line = TryReadLineNonBlocking(pending);
1552+
if (line is null)
15591553
{
1560-
// Turn ended; re-queue an already-typed line as a follow-up so
1561-
// it is not lost. It races the completion, so treat it as input
1562-
// for the next prompt rather than steering.
1563-
if (readTask.IsCompletedSuccessfully && readTask.Result is { Length: > 0 } tail)
1564-
{
1565-
followUps.Enqueue(StripFollowUpPrefix(tail.Trim()));
1566-
}
1567-
1568-
break;
1554+
await Task.WhenAny(turn, Task.Delay(50, CancellationToken.None)).ConfigureAwait(false);
1555+
continue;
15691556
}
15701557

1571-
string? line = readTask.Result?.Trim();
1572-
if (string.IsNullOrEmpty(line))
1558+
line = line.Trim();
1559+
if (line.Length == 0)
15731560
{
15741561
continue;
15751562
}
@@ -1604,6 +1591,17 @@ private static async ValueTask RunTurnWithSteeringAsync(
16041591
AnsiConsole.MarkupLine("[dim]queued steering (delivered after current tool calls)[/]");
16051592
}
16061593

1594+
// A partial line typed as the turn ended becomes the next prompt seed.
1595+
if (pending.Length > 0)
1596+
{
1597+
followUps.Enqueue(StripFollowUpPrefix(pending.ToString().Trim()));
1598+
}
1599+
1600+
await AwaitTurnAsync(turn, cancellationToken).ConfigureAwait(false);
1601+
}
1602+
1603+
private static async ValueTask AwaitTurnAsync(Task turn, CancellationToken cancellationToken)
1604+
{
16071605
try
16081606
{
16091607
await turn.ConfigureAwait(false);
@@ -1614,6 +1612,45 @@ private static async ValueTask RunTurnWithSteeringAsync(
16141612
}
16151613
}
16161614

1615+
/// <summary>
1616+
/// Drains available keys without blocking. Returns a completed line when
1617+
/// Enter arrives, null otherwise; partial input accumulates in the buffer.
1618+
/// Backspace edits the buffer; other control keys are ignored.
1619+
/// </summary>
1620+
private static string? TryReadLineNonBlocking(StringBuilder pending)
1621+
{
1622+
while (Console.KeyAvailable)
1623+
{
1624+
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
1625+
if (key.Key == ConsoleKey.Enter)
1626+
{
1627+
Console.WriteLine();
1628+
string line = pending.ToString();
1629+
pending.Clear();
1630+
return line;
1631+
}
1632+
1633+
if (key.Key == ConsoleKey.Backspace)
1634+
{
1635+
if (pending.Length > 0)
1636+
{
1637+
pending.Length--;
1638+
Console.Write("\b \b");
1639+
}
1640+
1641+
continue;
1642+
}
1643+
1644+
if (key.KeyChar != '\0' && !char.IsControl(key.KeyChar))
1645+
{
1646+
pending.Append(key.KeyChar);
1647+
Console.Write(key.KeyChar);
1648+
}
1649+
}
1650+
1651+
return null;
1652+
}
1653+
16171654
private static string StripFollowUpPrefix(string line) =>
16181655
line.StartsWith(">>", StringComparison.Ordinal) ? line[2..].Trim() : line;
16191656

@@ -1622,10 +1659,7 @@ private static string StripFollowUpPrefix(string line) =>
16221659
/// on the same line becomes the first line of the block. Returns null on
16231660
/// EOF, the joined block otherwise.
16241661
/// </summary>
1625-
private static async ValueTask<string?> ReadMultiLineBlockAsync(
1626-
string openingLine,
1627-
System.Threading.Channels.ChannelReader<string?> stdin,
1628-
CancellationToken cancellationToken)
1662+
private static string? ReadMultiLineBlock(string openingLine)
16291663
{
16301664
List<string> lines = [];
16311665
string remainder = openingLine[EditorInput.MultiLineMarker.Length..].Trim();
@@ -1637,7 +1671,7 @@ private static string StripFollowUpPrefix(string line) =>
16371671
AnsiConsole.MarkupLine("[dim]multi-line input — end with \"\"\" on its own line[/]");
16381672
while (true)
16391673
{
1640-
string? line = await stdin.ReadAsync(cancellationToken).ConfigureAwait(false);
1674+
string? line = Console.ReadLine();
16411675
if (line is null)
16421676
{
16431677
return null;

0 commit comments

Comments
 (0)