@@ -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 /// ">> 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