Skip to content

Commit 7ca7573

Browse files
authored
fix(tui): pin the platform default driver — TG 2.4.17 auto-detect picks the keypress-dropping ansi driver (#51)
Terminal.Gui 2.4.17 added an 'ansi' driver and its auto-detect now selects it on a bare terminal (IDriver.GetName() confirms), superseding 2.4.16's 'windows' default. The ansi driver's input path drops every other keypress: vim j/k moves once per two presses (input eaten — the second press moves one row — in both lists, persistently). Reproduces on main under COBALT_DRIVER=ansi; windows and dotnet are clean. ResolveDriver now pins the pre-2.4.17 platform default explicitly (windows on Windows, dotnet elsewhere) instead of falling through to TG auto-detect; the multiplexer/RDP path degrades to the same pin if 'dotnet' is ever unregistered. null (TG picks) remains only when even the pinned driver is missing. COBALT_DRIVER still overrides everything, including =ansi. Also records in ADR 0016 that INPUT-1's owed both-driver UAT passed 2026-07-22 (the double-press seen at that UAT was this ansi driver, initially misattributed to INPUT-1).
1 parent 6048bb9 commit 7ca7573

5 files changed

Lines changed: 119 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
### Fixed
6+
- **Vim `j`/`k` no longer needs two presses per move on a bare terminal.** Terminal.Gui
7+
2.4.17's auto-detect selects its new `ansi` driver, whose input path drops every other
8+
keypress. Cobalt now pins the platform default explicitly (`windows` on Windows, `dotnet`
9+
elsewhere) instead of falling through to auto-detect; `COBALT_DRIVER` still overrides
10+
everything, including `=ansi`. See
11+
[ADR 0016](docs/adr/0016-terminal-driver-selection.md).
12+
513
### Changed
614
- **The work-item list is capped at the first 200 assigned items**, matching the existing
715
pull-request-list cap: a heavy assignee no longer pulls an unbounded id set (and that many
@@ -20,8 +28,8 @@
2028
precomputed once per thread refresh instead of scanning every file's threads on each render,
2129
and the intra-line word diff skips its expensive comparison on line pairs whose lengths are
2230
too mismatched for the result to be useful. Vim movement now redraws only the moved list
23-
instead of repainting the whole app on every keystroke (needs both-driver UAT before it's
24-
fully trusted — see [ADR 0016](docs/adr/0016-terminal-driver-selection.md)). Per-keystroke
31+
instead of repainting the whole app on every keystroke (both-driver UAT passed 2026-07-22 —
32+
see [ADR 0016](docs/adr/0016-terminal-driver-selection.md)). Per-keystroke
2533
input handling (the key router and tokenizer) no longer allocates on the hot path. These are
2634
structural fixes verified by allocation/call-count tests, not measured against a live org.
2735
- **Fewer round-trips on work items.** Opening the work-item detail now fetches its comments and

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,14 @@ A full-screen editor gets a clean terminal (the TUI suspends while it runs).
196196

197197
## Terminal multiplexers (zellij / tmux) and remote sessions
198198

199+
On a bare terminal cobalt pins the platform default driver itself — `windows` on Windows,
200+
`dotnet` elsewhere — rather than deferring to Terminal.Gui's auto-detect, which (since
201+
TG 2.4.17) selects an `ansi` driver that drops every other keypress.
202+
199203
Inside a multiplexer, cobalt runs against a pseudo-terminal rather than a real Win32
200-
console, and Terminal.Gui's default `windows` driver (Win32 console APIs) drops
201-
keystrokes and breaks the `$EDITOR` handoff there. cobalt **auto-detects zellij and
202-
tmux** (`ZELLIJ`/`TMUX`) and switches to the stdio/ANSI `dotnet` driver, so it works
203-
there with no configuration.
204+
console, and the Win32 `windows` driver (console APIs) drops keystrokes and breaks the
205+
`$EDITOR` handoff there. cobalt **auto-detects zellij and tmux** (`ZELLIJ`/`TMUX`) and
206+
switches to the stdio/ANSI `dotnet` driver, so it works there with no configuration.
204207

205208
**Remote / RDP sessions** (including a Windows 365 Cloud PC) get the same treatment:
206209
cobalt detects them via `SESSIONNAME=RDP-*` and switches to `dotnet`. On the `windows`

docs/adr/0016-terminal-driver-selection.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,22 @@ was dropped before any handler ran. The record is corrected here and in the code
6565
misses, set `COBALT_DRIVER=dotnet`. And `COBALT_DRIVER` always wins — including
6666
`COBALT_DRIVER=windows` to force the Win32 driver back inside a multiplexer.
6767

68-
### Targeted redraw on vim movement (round 2, INPUT-1) — needs both-driver UAT
68+
- **Pin the platform default explicitly; never fall through to TG auto-detect (amended
69+
2026-07-22).** Terminal.Gui 2.4.17 added an `ansi` driver, and its auto-detect now selects
70+
it (verified empirically: `IDriver.GetName()` reports `ansi` after `Init(null)` on a bare
71+
Windows Terminal — the "`null` → TG picks `windows`" claims above describe 2.4.16 and are
72+
superseded). The ansi driver's input path drops every other keypress: vim `j`/`k` moves
73+
once per two presses (the second press moves 1 row, both lists, persistent — input eaten,
74+
not a repaint lag). Reproduces identically on `main` and on feature branches under
75+
`COBALT_DRIVER=ansi`, while `windows` and `dotnet` are clean, so it is the driver, not
76+
cobalt logic. `ResolveDriver` therefore pins the pre-2.4.17 platform default explicitly
77+
(`windows` on Windows, `dotnet` elsewhere) when no override and no multiplexer/RDP
78+
detection applies — and the multiplexer/RDP path degrades to that same pin (never to TG
79+
auto) if the `dotnet` driver is ever unregistered. `null` (TG picks) remains only as the
80+
last resort when even the pinned driver is missing. `COBALT_DRIVER=ansi` still passes
81+
through for retesting the upstream bug.
82+
83+
### Targeted redraw on vim movement (round 2, INPUT-1) — both-driver UAT passed
6984

7085
The earlier `LayoutAndDraw(false)→(true)` change (`fb5b777`, see Context above) forced a full
7186
`Application.LayoutAndDraw(true)` on every vim move to dodge a driver dirty-flag quirk, even
@@ -74,12 +89,11 @@ moved list view calls `SetNeedsDraw()` on itself, and the app then runs
7489
`Application.LayoutAndDraw(false)` — no forced full-app repaint — relying on the explicit dirty
7590
flag to cover what `force:true` was compensating for on a programmatic `InvokeCommand` move.
7691

77-
This is **not yet confirmed safe on both drivers** and must be UAT'd on the `windows` driver and
78-
the `dotnet` driver before it can be trusted — a headless test can assert `SetNeedsDraw()` was
79-
called, but not that the real driver actually repaints the moved row without the old `force:true`
80-
backstop, which is exactly the class of defect this ADR exists to catch (dropped input/redraw
81-
behaviour that only reproduces against a real console or pty). Until that UAT runs, treat the
82-
change as at-risk of resurrecting the redraw side of the original driver quirk.
92+
**UAT passed 2026-07-22** on both the `windows` and `dotnet` drivers: vim `j`/`k` movement
93+
paints correctly on the first press with INPUT-1's targeted redraw in place. (The double-press
94+
observed at that UAT was the 2.4.17 `ansi` auto-detect driver — see the 2026-07-22 pin above —
95+
initially misattributed to INPUT-1 precisely because the symptom matched the original
96+
`fb5b777`-era report. INPUT-1 is exonerated.)
8397

8498
## Consequences
8599

src/Cobalt.Tui/App/CobaltTuiApp.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Runtime.InteropServices;
12
using Cobalt.Core.Ado;
23
using Cobalt.Core.Auth;
34
using Cobalt.Core.Config;
@@ -57,7 +58,10 @@ private static int RunCore(CobaltConfig config, string? contextOverride, ITokenP
5758
context.PrScope,
5859
new PolicyApi(connection.Http));
5960

60-
var driverName = ResolveDriver(Environment.GetEnvironmentVariable, DriverRegistry.GetDriverNames().ToArray());
61+
var driverName = ResolveDriver(
62+
Environment.GetEnvironmentVariable,
63+
DriverRegistry.GetDriverNames().ToArray(),
64+
RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
6165
// Enable Terminal.Gui's theming (scoped to its embedded config) before Init so the driver
6266
// starts on the resolved theme; the initial preset is applied just below.
6367
ThemeService.Enable();
@@ -84,7 +88,11 @@ private static int RunCore(CobaltConfig config, string? contextOverride, ITokenP
8488
/// unknown value throws an actionable <see cref="ConfigException"/>). Otherwise the
8589
/// <c>dotnet</c> driver is selected when a terminal multiplexer (<c>ZELLIJ</c>/<c>TMUX</c>)
8690
/// or a remote/RDP session (<c>SESSIONNAME=RDP-*</c>) is detected. Failing all of these,
87-
/// <see langword="null"/> lets TG auto-detect (<c>windows</c> on Windows).
91+
/// the platform default is pinned explicitly — <c>windows</c> on Windows, <c>dotnet</c>
92+
/// elsewhere — never Terminal.Gui's auto-detect: since 2.4.17 auto-detect selects the new
93+
/// <c>ansi</c> driver, whose input path drops every other keypress (vim <c>j</c>/<c>k</c>
94+
/// needs two presses per move; diagnosed 2026-07-22). <see langword="null"/> (TG picks) is
95+
/// only the last resort when even the pinned driver is unregistered.
8896
///
8997
/// <para>The Win32-console <c>windows</c> driver is unreliable through a multiplexer's
9098
/// pseudo-terminal — it drops keystrokes and mishandles the editor suspend/resume — and
@@ -94,7 +102,8 @@ private static int RunCore(CobaltConfig config, string? contextOverride, ITokenP
94102
/// Set <c>COBALT_DRIVER</c> explicitly to override (e.g. <c>=windows</c> to force it back,
95103
/// or for an environment this detection misses). See ADR 0016.</para>
96104
/// </summary>
97-
internal static string? ResolveDriver(Func<string, string?> env, IReadOnlyCollection<string> knownDrivers)
105+
internal static string? ResolveDriver(
106+
Func<string, string?> env, IReadOnlyCollection<string> knownDrivers, bool isWindows)
98107
{
99108
var requested = env("COBALT_DRIVER")?.Trim();
100109
if (!string.IsNullOrEmpty(requested))
@@ -111,11 +120,23 @@ private static int RunCore(CobaltConfig config, string? contextOverride, ITokenP
111120
// expensive over a latency link on a GPU-less host, where the terminal renders in
112121
// software. The stdio/ANSI 'dotnet' driver writes VT straight to stdout and skips it.
113122
var inRemoteSession = env("SESSIONNAME")?.StartsWith("RDP-", StringComparison.OrdinalIgnoreCase) == true;
114-
return inMultiplexer || inRemoteSession
115-
// FirstOrDefault, not First: if 'dotnet' is somehow unregistered, fall back to
116-
// TG's default (null) rather than throwing into the crash boundary.
117-
? knownDrivers.FirstOrDefault(d => d.Equals("dotnet", StringComparison.OrdinalIgnoreCase))
118-
: null;
123+
if (inMultiplexer || inRemoteSession)
124+
{
125+
var dotnet = knownDrivers.FirstOrDefault(d => d.Equals("dotnet", StringComparison.OrdinalIgnoreCase));
126+
if (dotnet is not null)
127+
{
128+
return dotnet;
129+
}
130+
// 'dotnet' unregistered: fall through to the platform pin — deterministic beats
131+
// TG auto-detect (the broken 'ansi' driver).
132+
}
133+
134+
// Pin the pre-2.4.17 platform default rather than returning null: null hands the
135+
// choice to TG auto-detect, which since 2.4.17 lands on the keypress-dropping 'ansi'
136+
// driver. FirstOrDefault, not First: with even this driver unregistered, null (TG
137+
// picks) is still better than throwing into the crash boundary.
138+
var preferred = isWindows ? "windows" : "dotnet";
139+
return knownDrivers.FirstOrDefault(d => d.Equals(preferred, StringComparison.OrdinalIgnoreCase));
119140
}
120141

121142
/// <summary>

tests/Cobalt.Tui.Tests/App/DriverResolutionTests.cs

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
namespace Cobalt.Tui.Tests.App;
66

77
/// <summary>
8-
/// The COBALT_DRIVER escape hatch: forces a specific Terminal.Gui driver (needed under
9-
/// terminal multiplexers like zellij/tmux, where TG's default Win32-console 'windows'
10-
/// driver drops keystrokes and mishandles the editor suspend/resume). Unset = TG default.
8+
/// Driver selection (ADR 0016): an explicit COBALT_DRIVER wins; a multiplexer/RDP session
9+
/// selects 'dotnet'; otherwise the platform default is pinned explicitly ('windows' on
10+
/// Windows, 'dotnet' elsewhere) — never Terminal.Gui's auto-detect, whose 2.4.17 pick (the
11+
/// new 'ansi' driver) drops every other keypress.
1112
/// </summary>
1213
public class DriverResolutionTests
1314
{
@@ -29,33 +30,49 @@ public class DriverResolutionTests
2930
};
3031

3132
[Fact]
32-
public void Unset_Resolves_To_Null_Default()
33+
public void Unset_Pins_The_Windows_Driver_On_Windows()
3334
{
34-
Assert.Null(CobaltTuiApp.ResolveDriver(Env(null), Known));
35+
// Never null: null would hand selection to TG auto-detect, which picks 'ansi' in
36+
// 2.4.17 — and the ansi driver eats every other keypress (the j/k double-press bug).
37+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(Env(null), Known, isWindows: true));
3538
}
3639

3740
[Fact]
38-
public void Blank_Resolves_To_Null_Default()
41+
public void Unset_Pins_The_Dotnet_Driver_Off_Windows()
3942
{
40-
Assert.Null(CobaltTuiApp.ResolveDriver(Env(" "), Known));
43+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(Env(null), Known, isWindows: false));
44+
}
45+
46+
[Fact]
47+
public void Blank_Pins_The_Platform_Default()
48+
{
49+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(Env(" "), Known, isWindows: true));
4150
}
4251

4352
[Fact]
4453
public void Valid_Value_Resolves_And_Trims()
4554
{
46-
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(Env(" dotnet "), Known));
55+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(Env(" dotnet "), Known, isWindows: true));
4756
}
4857

4958
[Fact]
5059
public void Value_Is_Case_Insensitive_And_Canonicalized()
5160
{
52-
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(Env("DotNet"), Known));
61+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(Env("DotNet"), Known, isWindows: true));
62+
}
63+
64+
[Fact]
65+
public void Explicit_Ansi_Is_Still_Honoured()
66+
{
67+
// The pin only governs the default; an explicit COBALT_DRIVER=ansi is a deliberate
68+
// user choice (e.g. to retest the upstream bug) and passes through.
69+
Assert.Equal("ansi", CobaltTuiApp.ResolveDriver(Env("ansi"), Known, isWindows: true));
5370
}
5471

5572
[Fact]
5673
public void Unknown_Value_Throws_With_Actionable_Message()
5774
{
58-
var ex = Assert.Throws<ConfigException>(() => CobaltTuiApp.ResolveDriver(Env("dotnett"), Known));
75+
var ex = Assert.Throws<ConfigException>(() => CobaltTuiApp.ResolveDriver(Env("dotnett"), Known, isWindows: true));
5976
Assert.Contains("dotnett", ex.Message);
6077
Assert.Contains("windows", ex.Message);
6178
Assert.Contains("dotnet", ex.Message);
@@ -64,34 +81,42 @@ public void Unknown_Value_Throws_With_Actionable_Message()
6481
[Fact]
6582
public void Zellij_Selects_The_Dotnet_Driver()
6683
{
67-
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(zellij: "0"), Known));
84+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(zellij: "0"), Known, isWindows: true));
6885
}
6986

7087
[Fact]
7188
public void Tmux_Selects_The_Dotnet_Driver()
7289
{
73-
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(tmux: "/tmp/tmux-1000/default,1234,0"), Known));
90+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(tmux: "/tmp/tmux-1000/default,1234,0"), Known, isWindows: true));
91+
}
92+
93+
[Fact]
94+
public void Multiplexer_Without_A_Dotnet_Driver_Falls_Back_To_The_Platform_Pin()
95+
{
96+
// Defensive: if a future Terminal.Gui drops/renames the 'dotnet' driver, degrade to
97+
// the deterministic platform pin — never to TG auto-detect.
98+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(zellij: "0"), ["windows", "ansi"], isWindows: true));
7499
}
75100

76101
[Fact]
77-
public void Multiplexer_Without_A_Dotnet_Driver_Falls_Back_To_Null()
102+
public void Platform_Pin_Missing_From_The_Registry_Falls_Back_To_Null()
78103
{
79-
// Defensive: if a future Terminal.Gui drops/renames the 'dotnet' driver, auto-detect
80-
// degrades to TG's default rather than throwing (which would hit the crash boundary).
81-
Assert.Null(CobaltTuiApp.ResolveDriver(EnvVars(zellij: "0"), ["windows", "ansi"]));
104+
// Last resort only: with even the pinned driver unregistered, null lets TG pick
105+
// rather than throwing into the crash boundary.
106+
Assert.Null(CobaltTuiApp.ResolveDriver(EnvVars(), ["ansi"], isWindows: true));
82107
}
83108

84109
[Fact]
85110
public void Explicit_Value_Overrides_Multiplexer_Detection()
86111
{
87112
// COBALT_DRIVER=windows forces the Win32 driver even inside a multiplexer.
88-
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(cobaltDriver: "windows", zellij: "0"), Known));
113+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(cobaltDriver: "windows", zellij: "0"), Known, isWindows: true));
89114
}
90115

91116
[Fact]
92-
public void No_Override_And_No_Multiplexer_Is_Null()
117+
public void No_Override_And_No_Multiplexer_Pins_The_Platform_Default()
93118
{
94-
Assert.Null(CobaltTuiApp.ResolveDriver(EnvVars(), Known));
119+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(), Known, isWindows: true));
95120
}
96121

97122
[Theory]
@@ -103,27 +128,28 @@ public void Rdp_Session_Selects_The_Dotnet_Driver(string sessionName)
103128
// A remote/RDP session (e.g. a Windows 365 Cloud PC) paints through ConPTY's
104129
// console-buffer translation on the Win32 'windows' driver — expensive over a
105130
// latency link on a GPU-less host. The stdio/ANSI 'dotnet' driver skips it.
106-
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(sessionName: sessionName), Known));
131+
Assert.Equal("dotnet", CobaltTuiApp.ResolveDriver(EnvVars(sessionName: sessionName), Known, isWindows: true));
107132
}
108133

109134
[Fact]
110-
public void Console_Session_Stays_On_The_Default_Driver()
135+
public void Console_Session_Pins_The_Windows_Driver()
111136
{
112-
// A physical console (SESSIONNAME=Console) is unchanged: null → TG picks 'windows'.
113-
Assert.Null(CobaltTuiApp.ResolveDriver(EnvVars(sessionName: "Console"), Known));
137+
// A physical console (SESSIONNAME=Console) gets the same explicit pin as any
138+
// non-multiplexed Windows terminal.
139+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(sessionName: "Console"), Known, isWindows: true));
114140
}
115141

116142
[Fact]
117143
public void Explicit_Value_Overrides_Rdp_Detection()
118144
{
119145
// COBALT_DRIVER=windows forces the Win32 driver even in a remote session.
120-
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(cobaltDriver: "windows", sessionName: "RDP-Tcp#0"), Known));
146+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(cobaltDriver: "windows", sessionName: "RDP-Tcp#0"), Known, isWindows: true));
121147
}
122148

123149
[Fact]
124-
public void Rdp_Session_Without_A_Dotnet_Driver_Falls_Back_To_Null()
150+
public void Rdp_Session_Without_A_Dotnet_Driver_Falls_Back_To_The_Platform_Pin()
125151
{
126-
Assert.Null(CobaltTuiApp.ResolveDriver(EnvVars(sessionName: "RDP-Tcp#0"), ["windows", "ansi"]));
152+
Assert.Equal("windows", CobaltTuiApp.ResolveDriver(EnvVars(sessionName: "RDP-Tcp#0"), ["windows", "ansi"], isWindows: true));
127153
}
128154

129155
[Fact]

0 commit comments

Comments
 (0)