Skip to content

Introduce TUI server mode so ask_user can be intercepted without replacing entire TUI #1134

Description

@kzu

According to copilot analysis, there's a --ui-server switch in the sources of the SDK that could be used to preserve the full copilot TUI while intercepting events via TCP.

Unknowns to raise with the CLI team

  1. CLI flag name — SDK source comments say --ui-server; copilot --help shows --acp. Which flag actually starts "TUI+server" mode? Are they the same thing?

  2. ask_user routing — This is the key question. When the CLI is in TUI+server mode AND an SDK client is connected via TCP, does the CLI:

    a. Route userInput.request to the SDK client (over TCP), letting the SDK handler intercept it? ← what you need
    b. Handle ask_user in the TUI itself and not send userInput.request to the SDK at all?

    If it's currently (b), a small CLI-side change would be needed: when an SDK client is connected and has registered a userInput.request handler, prefer routing to the client.

  3. Port announcement — When running without --headless, does the CLI still write "Listening on port N" to stdout (now the terminal)? That's fine to leave in — users won't mind seeing
    it once on startup — but the SDK approach above bypasses it entirely by requiring an explicit port.

Proposal: Add TuiServerMode to CopilotClientOptions. Launches the CLI with its full terminal UX intact (streaming, colors, tool indicators, etc.) while the SDK connects via TCP to intercept only specific callbacks — specifically OnUserInputRequest — replacing the CLI's own prompt with a custom UX (e.g. a native popup). All stdout/stderr flow through to the user's terminal unchanged.

The SDK diff is ~30 lines. The only thing that may require CLI cooperation is whether userInput.request is already routed to TCP clients in TUI+server mode.

SDK changes needed (dotnet/src/)

  1. Types.cs — new option on CopilotClientOptions
 /// <summary>
 /// When true, launches the CLI in TUI+server mode (--ui-server) instead of
 /// headless mode. The CLI renders its own terminal UX while the SDK connects
 /// via TCP to intercept callbacks such as <see cref="SessionConfig.OnUserInputRequest"/>.
 /// Requires <see cref="Port"/> to be set (stdout is not available for port discovery).
 /// Mutually exclusive with <see cref="UseStdio"/>.
 /// </summary>
 public bool TuiServerMode { get; set; }
  1. Client.cs — StartCliServerAsync, args
 - args.AddRange(["--headless", "--no-auto-update", "--log-level", options.LogLevel]);
 + if (options.TuiServerMode)
 +     args.Add("--ui-server");   // ← CLI flag name TBD (see unknowns)
 + else
 +     args.Add("--headless");
 + args.AddRange(["--no-auto-update", "--log-level", options.LogLevel]);
  1. Client.cs — StartCliServerAsync, ProcessStartInfo
   var startInfo = new ProcessStartInfo
   {
       ...
 -     RedirectStandardInput  = options.UseStdio,
 -     RedirectStandardOutput = true,
 -     RedirectStandardError  = true,
 -     CreateNoWindow         = true,
 +     RedirectStandardInput  = options.UseStdio && !options.TuiServerMode,
 +     RedirectStandardOutput = !options.TuiServerMode,
 +     RedirectStandardError  = !options.TuiServerMode,
 +     CreateNoWindow         = !options.TuiServerMode,
   };
  1. Client.cs — StartCliServerAsync, port discovery
 - var detectedLocalhostTcpPort = (int?)null;
 - if (!options.UseStdio)
 - {
 -     // reads port announcement from stdout...
 - }
 + var detectedLocalhostTcpPort = options.TuiServerMode
 +     ? options.Port   // must be pre-configured; stdout is the user's terminal
 +     : await DetectPortFromStdoutAsync(options, cliProcess, cancellationToken);

(extract the existing port-reading loop into DetectPortFromStdoutAsync)

  1. Client.cs — validation in constructor
 + if (options.TuiServerMode && options.UseStdio)
 +     throw new ArgumentException("TuiServerMode is mutually exclusive with UseStdio");
 + if (options.TuiServerMode && options.Port <= 0)
 +     throw new ArgumentException("TuiServerMode requires Port to be set explicitly");

The stderr capture loop (lines ~1247–1265) is also skipped when TuiServerMode — otherwise it would try to read from an unredirected stream.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions