diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0f2ed5..0a642e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ What changed in each release. The section for a version is lifted into that vers
Newest first. Add a section before tagging.
+## 0.6.1 - 2026-08-21
+
+- **The interactive mode stops talking to the terminal when nobody is using it.** Terminal.Gui runs its main loop 25 times a second whether or not anything has changed, and rewrites cursor state every time round: sitting there with nothing happening, the tool was emitting ~315 bytes a second in 25 separate writes, for as long as it was open. The loop now steps down after ten seconds untouched and again after a minute. Measured idle output falls from 315 bytes/sec to 128 after a short pause and to 54 after a long one.
+- Typing is not affected: ten keypresses measured at 29-49 ms before the change and 29-49 ms after, because ten seconds is far longer than any pause in typing. What it costs is the single keypress that wakes it up after a long pause, measured over four attempts at 248, 60, 235 and 70 ms - up to a quarter of a second, once, and everything after it is back to normal.
+
+This is a candidate fix for "the UI goes laggy after a few minutes", not a confirmed one, and it is worth being straight about which. On a local terminal the lag does not reproduce: twelve minutes idle held latency flat at 44-73 ms, CPU at 2.1% and file handles constant, and 150 open-and-close cycles of the channel editor held latency flat at ~60 ms with no handle growth. What the tool was doing wrong regardless is the constant output, which over SSH is 25 packets a second the far end can never stop servicing. If it still goes laggy, the thing to say is which terminal and what connection - SSH, tmux, mosh, Windows Terminal - because that is where the remaining suspects live.
+
## 0.6.0 - 2026-08-21
- **Keyboard navigation between panels.** `F6` (and `Shift+F6`) moves between Radio, Channels, PDN preset and Log; `Tab` moves within a panel. The panel holding the keyboard now lights its border white, so where you are is visible rather than guesswork. Previously `Tab` could not leave the Radio panel at all, which also meant `Enter` on the channel list never fired: the list could not be reached.
diff --git a/README.md b/README.md
index 70c1934..8eb1a4d 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,8 @@ Run it with no arguments and you get a screen instead of a verb: pick a port, re
The radio work runs off the UI thread, so the screen stays live through the ~25s read and the 90s the connect will wait for your power-cycle.
+Left alone, it goes quiet: the main loop steps down after ten seconds untouched and again after a minute, so an editor left open over SSH is not writing to your terminal 25 times a second all afternoon. Typing is unaffected; the one key that wakes it after a long pause can take up to a quarter of a second to register, and everything after it is normal.
+
Colours are true-colour: a dark slate palette, green for read, amber for write (it is the one that changes your radio), red for errors. Terminal.Gui maps them down on a 16- or 256-colour terminal, so it stays legible on a plain console.
To try the editor without a radio on the bench, open a saved codeplug: `tait-codeplug tui radio.m8p`.
diff --git a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj
index a675803..a7ac0c2 100644
--- a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj
+++ b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj
@@ -12,6 +12,10 @@
+
+
+
+
diff --git a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs
index b1c5a6e..2f95419 100644
--- a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs
+++ b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs
@@ -44,12 +44,18 @@ internal static class Tui
private static Window _window = null!;
private static IApplication _app = null!;
+ /// The loop rate the library starts with, restored the moment a key or mouse event arrives.
+ private static ushort _activeIterationsPerSecond;
+
+ private static DateTime _lastInputUtc = DateTime.UtcNow;
+
internal static int Run(CodeplugImage? initial = null, string? source = null)
{
_app = Application.Create();
try
{
_app.Init();
+ GoQuietWhenLeftAlone();
TuiTheme.Apply();
_window = Build();
if (initial is not null)
@@ -70,12 +76,44 @@ internal static int Run(CodeplugImage? initial = null, string? source = null)
}
finally
{
+ // The loop rate is a static on the library, so hand it back as we found it rather than
+ // leaving a slowed-down rate behind for anything that runs the TUI again in-process.
+ if (_activeIterationsPerSecond > 0)
+ {
+ Application.MaximumIterationsPerSecond = _activeIterationsPerSecond;
+ }
+
_app.Dispose();
}
return 0;
}
+ ///
+ /// Stop talking to the terminal when nobody is using the tool. The main loop otherwise ticks - and
+ /// rewrites cursor state - 25 times a second for ever, which is invisible locally and is a redraw
+ /// the far end of an SSH link can never stop servicing. has the numbers.
+ ///
+ private static void GoQuietWhenLeftAlone()
+ {
+ _activeIterationsPerSecond = Application.MaximumIterationsPerSecond;
+ _lastInputUtc = DateTime.UtcNow;
+
+ _app.Keyboard.KeyDown += (_, _) => NoteInput();
+ _app.Mouse.MouseEvent += (_, _) => NoteInput();
+
+ _app.Iteration += (_, _) =>
+ Application.MaximumIterationsPerSecond =
+ TuiIdlePolicy.RateFor(DateTime.UtcNow - _lastInputUtc, _activeIterationsPerSecond);
+ }
+
+ /// Someone is here: back to the normal loop rate, and the quiet clock restarts.
+ private static void NoteInput()
+ {
+ _lastInputUtc = DateTime.UtcNow;
+ Application.MaximumIterationsPerSecond = _activeIterationsPerSecond;
+ }
+
private static Window Build()
{
var win = new Window
diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs
new file mode 100644
index 0000000..b77ef95
--- /dev/null
+++ b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs
@@ -0,0 +1,59 @@
+namespace M0LTE.Tait.Codeplug.Cli;
+
+///
+/// How fast the interactive mode's main loop should tick, given how long it is since anyone touched
+/// the keyboard or mouse.
+///
+/// Terminal.Gui iterates its main loop ~25 times a second whether or not anything has changed, and
+/// every iteration rewrites cursor state whether or not the cursor moved. Measured on an idle TUI
+/// with nothing happening at all: ~318 bytes/second of escape sequences, in 25 separate writes per
+/// second, for as long as the tool is open. Locally that costs nothing you would notice. Over SSH it
+/// is 25 packets a second the far end must keep servicing for ever, and a link that is congested or
+/// stalls never gets a quiet moment to catch up.
+///
+/// So the loop runs at the library's normal rate while the tool is being used, and steps down when it
+/// is left alone. Stepping down in two stages keeps the cost off the cases that would be noticed: a
+/// pause to think does not slow anything perceptibly, and only walking away drops it right down.
+///
+/// touched within 10s library rate (25/s) ~315 bytes/sec typing unaffected
+/// 10s to 60s 10/s ~128 bytes/sec waking key measured at 45ms
+/// over 60s 4/s ~54 bytes/sec waking key measured at 60-248ms
+///
+/// The first keypress after a pause pays that once, and restores the fast rate for everything after
+/// it. Ten seconds is far longer than any pause in typing, so the cost never lands on a burst of
+/// keystrokes.
+///
+internal static class TuiIdlePolicy
+{
+ /// A pause long enough that a slightly slower loop will not be felt.
+ internal static readonly TimeSpan PausedAfter = TimeSpan.FromSeconds(10);
+
+ /// Long enough that whoever started the tool has walked away from it.
+ internal static readonly TimeSpan AwayAfter = TimeSpan.FromSeconds(60);
+
+ /// Iterations per second during a pause: 100ms worst case, which reads as instant.
+ internal const ushort PausedIterationsPerSecond = 10;
+
+ /// Iterations per second once nobody is there. 250ms worst case on the key that wakes it.
+ internal const ushort AwayIterationsPerSecond = 4;
+
+ ///
+ /// The rate to run at. is whatever the library was configured with at
+ /// startup, so this never invents a rate of its own for the active case, and never speeds the loop
+ /// up beyond what the library asked for.
+ ///
+ internal static ushort RateFor(TimeSpan sinceLastInput, ushort activeRate)
+ {
+ if (sinceLastInput >= AwayAfter)
+ {
+ return Math.Min(AwayIterationsPerSecond, activeRate);
+ }
+
+ if (sinceLastInput >= PausedAfter)
+ {
+ return Math.Min(PausedIterationsPerSecond, activeRate);
+ }
+
+ return activeRate;
+ }
+}
diff --git a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj
index 20bc487..3be6e5d 100644
--- a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj
+++ b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj
@@ -2,6 +2,7 @@
+
diff --git a/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs
new file mode 100644
index 0000000..b75e506
--- /dev/null
+++ b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs
@@ -0,0 +1,70 @@
+using M0LTE.Tait.Codeplug.Cli;
+
+namespace M0LTE.Tait.Codeplug.Tests;
+
+///
+/// The interactive mode steps its main loop down when it is left alone, so that a tool sitting there
+/// doing nothing stops writing to the terminal 25 times a second. The rates themselves are measured
+/// (see ); what is worth pinning down here is the shape of the decision,
+/// because getting it wrong is either a tool that feels sluggish to type into or one that never goes
+/// quiet.
+///
+public class TuiIdlePolicyTests
+{
+ private const ushort LibraryRate = 25;
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(9)]
+ [InlineData(9.999)]
+ public void Typing_runs_at_the_librarys_own_rate(double secondsSinceInput)
+ {
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate)
+ .Should().Be(LibraryRate, "a pause shorter than the threshold must not slow typing down");
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(30)]
+ [InlineData(59.999)]
+ public void A_pause_steps_down_to_a_rate_that_still_reads_as_instant(double secondsSinceInput)
+ {
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate)
+ .Should().Be(TuiIdlePolicy.PausedIterationsPerSecond);
+ }
+
+ [Theory]
+ [InlineData(60)]
+ [InlineData(3600)]
+ public void Walking_away_steps_down_again(double secondsSinceInput)
+ {
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate)
+ .Should().Be(TuiIdlePolicy.AwayIterationsPerSecond);
+ }
+
+ [Fact]
+ public void The_step_down_is_monotonic()
+ {
+ ushort previous = LibraryRate;
+
+ for (double s = 0; s <= 120; s += 0.5)
+ {
+ ushort rate = TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(s), LibraryRate);
+ rate.Should().BeLessThanOrEqualTo(previous, $"the rate must never rise again at {s}s idle");
+ previous = rate;
+ }
+ }
+
+ [Fact]
+ public void It_never_speeds_the_loop_up_beyond_what_the_library_asked_for()
+ {
+ // A library (or a future version of it) that already ticks slowly than our idle rates must be
+ // left alone: stepping "down" to 10/s would be stepping up.
+ const ushort SlowLibrary = 2;
+
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(0), SlowLibrary).Should().Be(SlowLibrary);
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(30), SlowLibrary).Should().Be(SlowLibrary);
+ TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(300), SlowLibrary).Should().Be(SlowLibrary);
+ }
+}