XenoAtom.Terminal.UI supports async / await, but only in specific places and with a specific threading model.
The short version:
Terminal.LiveAsync(...) and Terminal.RunAsync(...) support an asynchronous onUpdate callback.SynchronizationContext while the app is running.Click(...) are synchronous. Treat async void handlers as unsupported.If you keep that model in mind, the rest of the behavior is predictable.
Think of Terminal.UI as a single-threaded UI loop with an optional asynchronous update coroutine.
Terminal.Run(...) / Terminal.Live(...) run the host loop on the calling thread.Terminal.RunAsync(...) / Terminal.LiveAsync(...) do not move the loop to another thread.onUpdate callback, not the existence of a background UI loop.While a hosted app is running, Terminal.UI binds SynchronizationContext.Current to its dispatcher. That means a normal:
await SomeOperationAsync();
inside the async update callback captures the UI context by default and resumes there later.
awaitWhen the async update callback reaches an incomplete await:
That "later loop iteration" detail matters:
There is only one hosted async update callback in flight at a time. Terminal.UI will not invoke onUpdate again until the current async update task has completed.
Awaiting in onUpdate does not freeze the app. While the callback task is pending, Terminal.UI still:
What pauses is only the next onUpdate invocation. That callback behaves like a cooperative coroutine, not like a re-entrant timer.
Most UI-facing types in Terminal.UI are dispatcher-bound:
VisualState<T>TerminalAppDispatcherObject-derived typesThey should be read and written on the UI thread.
If you update bound state from the wrong thread, Terminal.UI will reject it. For example, State<T>.Value writes are validated through the binding system and are expected to happen on the UI thread.
Use the dispatcher when background work needs to publish results to the UI:
await app.Dispatcher.InvokeAsync(() =>
{
result.Value = fetchedText;
isLoading.Value = false;
});
Use:
Dispatcher.Post(...) for fire-and-forget work queued to the UI thread;Dispatcher.InvokeAsync(...) when background code must await completion of a UI-thread update.ConfigureAwait(false) guidanceInside the async update callback, default await behavior is usually what you want:
await SomeOperationAsync();
status.Value = "Done"; // back on the UI thread
If you write this instead:
await SomeOperationAsync().ConfigureAwait(false);
status.Value = "Done";
the continuation may run on a thread-pool thread. At that point, touching status.Value, a Visual, or other UI state is no longer safe.
Guidance:
ConfigureAwait(false) in onUpdate if the continuation needs to touch UI state.ConfigureAwait(false) only for purely background continuation work.var text = await client.GetStringAsync(uri).ConfigureAwait(false);
await context.App.Dispatcher.InvokeAsync(() => status.Value = text);
The same rule applies to any background Task.Run(...), timer callback, or library callback: marshal back before mutating UI state.
onUpdateUse this when the hosted app itself is naturally driven by an async workflow:
var progress = new State<double>(0);
await Terminal.LiveAsync(
new ProgressBar().Value(progress),
async _ =>
{
await Task.Delay(50);
progress.Value = Math.Min(1.0, progress.Value + 0.05);
return progress.Value >= 1.0
? TerminalLoopResult.StopAndKeepVisual
: TerminalLoopResult.Continue;
});
This is the simplest pattern, and it keeps the continuation on the UI thread as long as you do not opt out with ConfigureAwait(false).
Routed events such as Click(...) are synchronous, so trigger intent there and perform the await in onUpdate.
var shouldLoad = new State<bool>(false);
var isLoading = new State<bool>(false);
var text = new State<string>("Idle");
var button = new Button("Load").Click(() =>
{
if (!isLoading.Value)
{
shouldLoad.Value = true;
}
});
await Terminal.RunAsync(
new VStack(button, new TextBlock().Text(() => text.Value)),
async _ =>
{
if (!shouldLoad.Value)
{
return TerminalLoopResult.Continue;
}
shouldLoad.Value = false;
isLoading.Value = true;
text.Value = "Loading...";
var loaded = await LoadTextAsync();
text.Value = loaded;
isLoading.Value = false;
return TerminalLoopResult.Continue;
});
This keeps event handling synchronous while still allowing the real async work to live in the supported async host callback.
Use this when the operation should run independently of the hosted update coroutine:
var isLoading = new State<bool>(false);
var text = new State<string>("Idle");
var button = new Button("Load");
button.Click(() =>
{
if (isLoading.Value)
{
return;
}
isLoading.Value = true;
text.Value = "Loading...";
_ = Task.Run(async () =>
{
try
{
var loaded = await LoadTextAsync().ConfigureAwait(false);
await Dispatcher.Current.InvokeAsync(() =>
{
text.Value = loaded;
isLoading.Value = false;
});
}
catch (Exception ex)
{
await Dispatcher.Current.InvokeAsync(() =>
{
text.Value = ex.Message;
isLoading.Value = false;
});
}
});
});
This pattern is appropriate when a click starts background work that should not occupy the hosted async update callback.
The current async support has explicit limits:
onUpdate callback per hosted app.Practical implications:
async void event handlers;.Result, .Wait(), or long synchronous I/O on the UI thread;This is supported:
button.Click(() => counter.Value++);
This is technically possible in C#, but not a supported Terminal.UI pattern:
button.Click(async () =>
{
await Task.Delay(100);
counter.Value++;
});
Why it is discouraged:
Click(...) is a synchronous routed event API;async void do not integrate cleanly with the host loop;Prefer one of the patterns above instead.
If the async update callback throws, the hosted run fails when Terminal.UI observes that task completion.
If the host cancellation token is canceled, the app stops on the UI thread just like the synchronous hosting APIs.
Keep cancellation explicit in long-running async work and propagate it to the operations you start.
LiveAsync / RunAsync when the host update itself needs to await.await behavior inside onUpdate unless you intentionally want to leave the UI context.State<T> and visuals as UI-thread-bound objects.Dispatcher.InvokeAsync(...) or Dispatcher.Post(...) to publish background results back to the UI.onUpdate or in background tasks that marshal back to the dispatcher.onUpdate is re-entrant; one async update callback runs at a time.