dcli is the sole owner of stdin and the render thread, so everything the user does reaches you as a
message on a channel. You drain terminal.Events on your own loop; dcli never calls into your
code. This page covers the event stream, how keys are encoded, and the sharp edges.
terminal.Events is a ChannelReader<TerminalEvent>. Drain it however you like:
await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync(cancellationToken))
{
// handle evt
}A consumer that never reads simply accumulates events; it does not stall the render loop. Drain on a dedicated task if your handling is slow.
TerminalEvent is a closed set of records. Pattern-match them:
| Event | Meaning |
|---|---|
InputSubmitted(string Text) |
User pressed Enter. Text is the line as submitted. |
InputChanged(string Text) |
The input buffer changed (user edit). Text is the new buffer. Drives autocomplete. |
KeyPressed(KeyEvent Key) |
A key the editor and overlays didn't consume, forwarded to you for app-level shortcuts. |
Resized(int Columns, int Rows) |
The terminal was resized. |
switch (evt)
{
case InputSubmitted(var text): Submit(text); terminal.Input.Clear(); break;
case InputChanged(var text): terminal.Autocomplete.Show(Complete(text)); break;
case Resized(var cols, var rows): Reflow(cols, rows); break;
case KeyPressed(var key): HandleShortcut(key); break;
}Only unconsumed keys arrive as KeyPressed. While the editor has focus it consumes printable
characters, Backspace, arrows, etc.; an open dialog or dropdown consumes its navigation keys first.
What's left over — function keys, Ctrl-chords you haven't bound, etc. — falls through to you.
Input.SetText/Input.Clearare programmatic and do not emitInputChanged(see the fixed region) — so setting text yourself won't trigger your autocomplete handler.
A KeyEvent is a KeyCode plus Modifiers.
public sealed record KeyEvent(KeyCode Code, Modifiers Modifiers);KeyCode is a discriminated value:
UnicodeScalar— a printable character (or a Ctrl-modified letter). ReadRuneValue.Named— a non-printable key fromNamedKey(arrows, F-keys, Home/End, Enter, Tab, Escape, Delete, …). ReadNamedValue.
void HandleShortcut(KeyEvent key)
{
switch (key.Code.Kind)
{
case KeyCode.KeyCodeKind.Named when key.Code.NamedValue == NamedKey.F5:
Refresh();
break;
case KeyCode.KeyCodeKind.UnicodeScalar
when key.Modifiers == Modifiers.Ctrl && key.Code.RuneValue == new Rune('r'):
ReverseSearch();
break;
}
}Reading the wrong accessor for the stored kind throws InvalidOperationException, so always check
Kind first (or match on it).
Modifiers is a [Flags] enum: None, Ctrl, Alt, Shift.
if (key.Modifiers.HasFlag(Modifiers.Alt)) { /* ... */ }VT terminals lose information that a richer protocol (e.g. Kitty) would preserve. These are documented limits, not defects:
- Ctrl-letter collisions.
Ctrl+Iis indistinguishable from Tab,Ctrl+Mfrom Enter,Ctrl+Hfrom Backspace — the terminal sends the same bytes. dcli reports them as the named key. - Shift is implicit on printables.
Shift+aarrives as the rune'A', not as'a'+ a Shift flag.Shiftonly appears on named keys (e.g.Shift+Tab→BackTab, Shift+Arrow). - Shift is dropped under Ctrl. The terminal collapses
Ctrl+Shift+xon the wire, so dcli never reportsCtrl | Shifttogether.
In raw mode, terminal signal generation is off — so Ctrl+C does not raise SIGINT. It arrives as
an ordinary key event: KeyEvent(KeyCode.FromRune('c'), Modifiers.Ctrl). dcli "eats" it and hands
it to you; you decide whether it means clear-input, interrupt the current operation, or exit.
case KeyPressed(var key)
when key.Modifiers == Modifiers.Ctrl
&& key.Code.Kind == KeyCode.KeyCodeKind.UnicodeScalar
&& key.Code.RuneValue == new Rune('c'):
return; // exit; `await using` restores the terminalExternal signals are different: a real SIGTERM/SIGINT/SIGQUIT from kill, or ProcessExit,
is caught by dcli's restore coordinator, which always restores the terminal before the process
dies — even on a crash. See Architecture.
Pasted text arrives via bracketed paste as a single unit, not as a flurry of key events. The
input editor inserts it literally. If you read raw InputEvents (advanced / testing), a paste is a
PasteEvent(string Text) with the full UTF-8-decoded text; feed-call
boundaries inside the paste body are transparent.
A terminal resize (POSIX SIGWINCH or a Windows buffer-size event) is delivered to you as
Resized(Columns, Rows). dcli also reflows the live window and fixed region itself; the event lets
you react (e.g. recompute a status line or re-wrap your own content). The current size is also
available synchronously via terminal.GetTerminalSize().
case Resized(var cols, var rows):
terminal.Status.SetRows(Line.FromText($"{cols}×{rows}"));
break;Two families exist, at different layers:
InputEvent(KeyEvent/PasteEvent/ResizeEvent) is the low-level VT-pipeline vocabulary. You mostly meet it viaKeyPressed.Keyand in the headless test harness, which scriptsKeyEvents andPasteEvents directly.TerminalEvent(InputSubmitted/InputChanged/KeyPressed/Resized) is the high-level outbound stream you actually consume in an app.
- The fixed region — what consumes keys before they reach you.
- Dialogs — modal flows that consume their own navigation keys.
- API reference.