From 60e815a715df41753dc9dc72c4a3097111922571 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 13 Aug 2026 22:08:20 -0500 Subject: [PATCH 1/3] fix(compose): a post you wrote is a post you can recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌃S reached the session directly and skipped OnCommandEntered, which holds the one seam that adds a history entry — so a composed post was echoed and alias-expanded (both free from SendUserInputAsync) and never recorded. SendComposed's own doc had claimed all three for as long as the composer existed. It keeps going direct: OnCommandEntered is the command *line's* seam and clears that window's bar draft, moves the unsent marker and owns the /web, /graphics and /triggers branches. It records the entry itself instead — the built line rather than the buffer, since history holds sendable commands, and through InputHistory.Add so a post carrying a connect line meets the same secret gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 24 +++++++++++++-- .../ComposeWindowTests.cs | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 203d373..1e97d01 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -3642,9 +3642,26 @@ private void ToggleComposer() ?? (_workspace.FindWindow(windowId)?.SessionKey is { Length: > 0 } owner ? owner : null); /// - /// Sends a composed post as one line, through the ordinary command path — so it is echoed, recorded - /// in history and alias-expanded exactly like the same text typed on the command line, which is what - /// the buffer is. The window closes on success and keeps the post on a refusal. + /// Sends a composed post as one line — echoed, alias-expanded and recorded in history exactly like the + /// same text typed on the command line, which is what the buffer is. The window closes on + /// success and keeps the post on a refusal. + /// + /// It reaches the session directly rather than through , and so records + /// the history entry itself. That seam is the command line's: it clears that window's bar + /// draft, moves the unsent-input marker and owns the /web, /graphics and /triggers + /// branches, none of which belong to a post written in a different window. The doc here claimed the + /// ordinary path for as long as the composer existed, and two thirds of the claim were true — the echo + /// and the alias expansion come free from SendUserInputAsync, and the history did not, which is + /// how a composed post became the one user-authored command in this client with no recall route at all. + /// + /// + /// The built line is what is kept, not : history holds + /// sendable commands, and a recalled entry lands on a one-command bar the raw multi-line buffer would + /// not fit. Through like every other entry, so a post carrying a connect + /// line meets the same secret gate — that gate lives inside Add precisely so no caller can get + /// round it. On the armed bar's list, because that is where ⌥↑ and ⌃R will look from where the user is + /// standing. + /// /// private void SendComposed(ComposeResult result) { @@ -3669,6 +3686,7 @@ private void SendComposed(ComposeResult result) // was opened, already sent. _composer.Close(); _composeDrafts.Remove(session.SessionKey); + HistoryFor(BarKind(ActiveBar())).Add(line); _ = session.SendUserInputAsync(line); } diff --git a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs index d99876d..50b40bb 100644 --- a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs @@ -108,6 +108,36 @@ public async Task AltLFlipsTheEscapingAndChangesWhatIsSent() await Assert.That(world.Telnet.Lines).IsEquivalentTo(new[] { "100%% \\[sure\\]" }); } + /// + /// A composed post is recallable afterwards, like anything else the user wrote and sent. It is the + /// built line that is kept, not the editor's buffer: history holds sendable commands, and a + /// recalled entry lands on a one-command bar that the raw multi-line buffer would not fit. + /// + [Test] + public async Task AComposedPostIsRecallableFromTheCommandHistory() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("+bbpost 12=Title\nfirst\nsecond"); + + world.App.Composer.SimulateKey(CtrlS); + + await Assert.That(world.App.HistoryEntries(InputBar.Primary)) + .Contains("+bbpost 12=Title%rfirst%rsecond"); + } + + /// A post that was refused is a post the user still has; nothing was sent, so nothing is recalled. + [Test] + public async Task ARefusedPostEntersNoHistory() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + world.App.Composer.SimulateKey(CtrlS); + + await Assert.That(world.App.HistoryEntries(InputBar.Primary)).IsEmpty(); + } + [Test] public async Task SendingAnEmptyComposerSaysSoAndSendsNothing() { From a194039fd566e6760306c6396402fdfb60ea522b Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 13 Aug 2026 22:17:02 -0500 Subject: [PATCH 2/3] feat(tabs): the key that walks a pane's tabs can now be found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌃N has cycled the focused pane's tab strip for as long as panes have held more than one window, and it was named on F4 and nowhere else. It is now a ⌃P entry (layout:next-tab) and a status-row segment (⌃N tab, shown while the focused pane has a second tab). The chord stays ⌃N because the familiar spellings do not arrive, measured at a raw reader rather than assumed: ⌃Tab is 09, byte-identical to Tab; ⌃⇧Tab is CSI Z, byte-identical to ⇧Tab; ⌥Tab is ESC + a control byte and so arrives as two key events, on a chord the compositor takes anyway. Listing a key obliges it to answer. NextWindow returned in silence on a single-tab pane — indistinguishable from a dead key — and now refuses out loud, beside the pane cycle's own wording. Every surface says tab rather than window; F4 and --help said window while the rest said tab, and ⌥N already owns the window noun. FocusHints is generated from a segment list instead of eight hand-written ladders, with reading order and drop order kept separate so the existing pane · size · line row is unchanged cell for cell. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 26 +++ docs/design/README.md | 2 +- .../Commands/CommandCatalog.cs | 11 ++ src/SharpMUTerm.Tui/MacroKeys.cs | 7 +- src/SharpMUTerm.Tui/Program.cs | 2 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 81 ++++++-- .../Commands/CommandCatalogTests.cs | 15 ++ tests/SharpMUTerm.Tui.Tests/TabCycleTests.cs | 180 ++++++++++++++++++ 8 files changed, 304 insertions(+), 20 deletions(-) create mode 100644 tests/SharpMUTerm.Tui.Tests/TabCycleTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 9e549e1..036ce35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -724,6 +724,32 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `TryRecallKey` now matches on — **exactly**, so `⌥⇧↑` (the pane resize) still reaches its own handler. A macro bound to `Alt+Up` wins over recall, because `DispatchMacro` runs first: the same relationship `Ctrl+←/→` has with pane selection. + - **`⌃N` walks the focused pane's tab strip, and it stays `⌃N` because the familiar spellings do not + exist here.** Asked for "an easy key-combination to tab through the tabs of the active pane", the + obvious candidates were measured at a raw reader with `kitten @ send-key` before anything was built: + `⌃Tab` is `09`, byte-identical to a bare Tab (already in `MacroKeys.ControlBytes`); `⌃⇧Tab` is + `CSI Z`, byte-identical to plain `⇧Tab`; and **`⌥Tab` is `ESC` + `09`**, which is `ESC` + a *control* + byte and so arrives as **two** key events rather than an Alt chord (`AnsiInputParser.ProcessEscape`). + A `TryAltEnter`-style reassembly could pair them, but Tab is already spent as + `TerminalFocusWatcher`'s disguised focus-in — and `send-key` writes into the pty, so it says nothing + about the *compositor*, which takes `⌥Tab` unconditionally on Windows, GNOME and KDE. `⌃PgUp`/`⌃PgDn` + is the one familiar pair that does arrive (`CSI 5;5~` / `CSI 6;5~`, decoded by `DispatchTilde`, and + free because `TryScrollKey` matches PageUp/PageDown only at `ctrl: false`) — kept in reserve rather + than spent, since the reported problem was that `⌃N` could not be *found*, not that it was wrong. + - **So the fix was discoverability, and the chord had to earn it by answering.** `⌃N` was named on F4 + and nowhere else. It is now a ⌃P entry (`layout:next-tab`, listed unconditionally like the + directional pane entries, because this surface is where a reader learns a pane holds tabs at all) and + a status-row segment (`⌃N tab`, shown exactly while the focused pane has a second tab, the same + contextual rule its neighbours follow). Listing a key obliges it to answer: `NextWindow` **returned + in silence** on a single-tab pane, which is indistinguishable from a dead key, and now refuses out + loud beside `PrefixPanel.NoCycleRefusal`'s wording. Every surface says **tab**, not "window" — F4 and + `--help` said window while everything else said tab, and `⌥N` already owns the window noun. + - **`FocusHints` separates reading order from drop order.** Three independent conditions is eight + cases, so the ladder is generated; but the row reads `pane · size · line` while *size* is the first + thing surrendered, so a generator that dropped from the end of the reading order would silently + reorder a row nobody asked to reorder. The tab segment is given up second, and that judgement is + written down: a pane's tabs are drawn as a strip you can see, so the hint names a shortcut to + something already visible, while nothing on screen says how to reach another pane or the second bar. - **Known and not fixed here**: `⌃N` and `⌃O` have no reverse (the character cycle does — `⌥J`/`⌥K`), and `⌃W` and `⌃B x` are two chords for one action. Both are shape complaints rather than defects, and both are behaviour changes rather than modifier moves. diff --git a/docs/design/README.md b/docs/design/README.md index 6947542..086b097 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -425,7 +425,7 @@ count on the tab, the rail character, and the rail world. `⌃P` command surface · `⌃F` search the output (`⌥G` next hit) · `⌥F` freeze/resume in focused pane · `⌃R` command-history search · -`⌃N` next window · `⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too, +`⌃N` next tab in the focused pane · `⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too, where the caret has nowhere further to go) · `⌥⏎`, or `⌃L`, newline in input · `F1` composer · `F2`–`F9` config · `Esc` close overlay. diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 3bf66cf..2d16928 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -250,6 +250,17 @@ public static IReadOnlyList Build( items.Add(new CommandItem(CommandGroup.Layout, "Focus pane down", "layout:focus-down", "⌃↓")); items.Add(new CommandItem(CommandGroup.Layout, "Focus the next pane", "layout:cycle", "⌃O · ⌃B o")); + // The tab cycle, beside the pane cycle it rhymes with. Listed unconditionally for the same reason + // the four directional entries above are: this surface is where the keyboard is learnt, and a + // reader whose panes each hold one window has no other way to find out that a pane holds tabs at + // all. ⌃N has always done this and was named on F4 and nowhere else. + // + // Listing it obliges it to answer, which the directional entries pay for by refusing out loud and + // this one did not — it returned in silence on a pane with one tab, which is what a dead key looks + // like. The refusal is the host's (SharpMUTermApp.NextWindow); the entry is only allowed to exist + // because it is there. + items.Add(new CommandItem(CommandGroup.Layout, "Focus the next tab", "layout:next-tab", "⌃N")); + // Numbered pane jumps, one entry per pane that exists — the one group here that is *not* listed // unconditionally, because "Go to pane 4" on a workspace with two panes names a place there is no // way to make. The number is the one the move and drag overlays badge each pane with, so the entry diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index 43f688b..cc1b5fa 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -105,7 +105,12 @@ private static AppShortcut[] BuildAppShortcuts() private static AppShortcut[] Fixed() => new AppShortcut[] { new(ConsoleModifiers.Control, ConsoleKey.Q, "asks whether to quit"), - new(ConsoleModifiers.Control, ConsoleKey.N, "picks the next window"), + // "the next tab in this pane", not "the next window", and the wording is the point. A tab *is* a + // window — but ⌥N goes to a numbered window anywhere in the workspace, and this walks the strip of + // the pane in front of you, so two keys described in the same noun read as two spellings of one + // action. F4, --help, the ⌃P entry and the status row all say tab now; they said window here and + // tab everywhere else, which is the drift the numbering vocabularies are kept apart to avoid. + new(ConsoleModifiers.Control, ConsoleKey.N, "goes to the next tab in this pane"), // ⌃Tab is deliberately absent, and its absence is measured rather than assumed: a terminal writes // 0x09 for it, byte-identical to a bare Tab (read off a pty with `kitten @ send-key`), so the // parser reports ConsoleKey.Tab with no Control bit and this claim could never once have matched. diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 4b00e07..fef155a 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -299,7 +299,7 @@ private static void WriteUsage(TextWriter usage) usage.WriteLine("'at start' only opens the connection. What is typed once one is open follows from the"); usage.WriteLine("character's saved password and connect line — F5's 'login' row says which."); usage.WriteLine(); - usage.WriteLine("In-app: Up/Down history · Ctrl+N next window · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit."); + usage.WriteLine("In-app: Up/Down history · Ctrl+N next tab · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit."); // The composer earns a line of its own because what it *sends* is not guessable from the window: // the buffer is one command and its line breaks are written %r, which is what a MUSH board or // mail body wants. Naming the send chord matters for the same reason — Ctrl+Enter is what a diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 1e97d01..639e1dd 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -6757,6 +6757,9 @@ internal bool DispatchCommand(string id) CyclePane(); return true; + case "layout:next-tab": + NextWindow(); // refuses out loud on a pane with one tab, the same as the chord does + return true; case "term:newline": // The same edit Alt+⏎ makes, through the same key table, so the surface cannot drift from // the chord it advertises. @@ -8319,13 +8322,33 @@ private void RebuildPaneArea() /// The TabControl of the focused pane, or null if none is realised. private TabControl? FocusedTabs() => _paneTabs.GetValueOrDefault(_workspace.Layout.FocusedPaneId); - /// Cycles to the next window tab in the focused pane, wrapping (⌃N). + /// + /// Why ⌃N can refuse. Named beside the pane cycle's own wording () + /// and phrased to match it, because the two keys are one gesture at two scales and a reader who has met + /// one should recognise the other. + /// + private const string NoTabCycleRefusal = "nowhere to cycle to — this pane has one tab"; + + /// + /// Cycles to the next window tab in the focused pane, wrapping (⌃N, and ⌃P ▸ Focus the next tab). + /// + /// A pane holding a single tab is refused out loud. It used to return in silence, which was + /// tolerable only while the chord was advertised nowhere but F4 — the moment the ⌃P surface lists it, + /// the key is held to the same rule as the directional pane entries beside it, every one of which says + /// why nothing happened. A key that is dead and a key that has nowhere to go look identical otherwise, + /// and this one is a wrap: on two tabs it always moves, so the state it is silent in is the state a + /// first-time reader is most likely to try it in. + /// + /// private void NextWindow() { - if (FocusedTabs() is { TabCount: > 1 } tabs) + if (FocusedTabs() is not { TabCount: > 1 } tabs) { - tabs.ActiveTabIndex = (tabs.ActiveTabIndex + 1) % tabs.TabCount; + RefuseCommand(NoTabCycleRefusal); + return; } + + tabs.ActiveTabIndex = (tabs.ActiveTabIndex + 1) % tabs.TabCount; } /// @@ -10561,26 +10584,50 @@ private string HeaderMarkup() /// navigation one, instead of losing both because the pair no longer fitted. The chord is still named /// on the ⌃P surface and in --help either way. /// + /// + /// The ladder is generated, not written out per combination. Three independent conditions is + /// eight cases, each needing its own ordered candidates, and eight hand-written ladders is eight + /// chances for one of them to drop the wrong segment. + /// + /// + /// Reading order and drop order are separate, and have to be. The row reads + /// pane · size · line — the two pane chords together, then the bars — while size is the + /// first thing given up. A generator that dropped from the end of the reading order would have to put + /// size last, which reorders a row nobody asked to have reordered. + /// /// private string[] FocusHints() { var panes = _workspace.Layout.Panes.Count > 1 && _workspace.Layout.ZoomedPaneId is null; var bars = _second.Visible; - return (panes, bars) switch - { - (true, true) => new[] - { - "[dim]⌃←→↑↓ pane · ⌥⇧←→↑↓ size · ⇥ line[/]", - "[dim]⌃←→↑↓ pane · ⇥ line[/]", - }, - (true, false) => new[] - { - "[dim]⌃←→↑↓ pane · ⌥⇧←→↑↓ size[/]", - "[dim]⌃←→↑↓ pane[/]", - }, - (false, true) => new[] { "[dim]⇥ · ⌃↑↓ line[/]" }, - _ => Array.Empty(), + var tabs = FocusedTabs() is { TabCount: > 1 }; + + // In reading order, each with the rank it is surrendered at — lowest goes first. + // + // Size is rank 0 as it always was: the longest claim for the least urgent fact. The tab cycle + // follows it, and that is the one judgement here worth stating — a pane's tabs are drawn as a + // strip the reader can see, so this hint names a shortcut to something already visible, while + // nothing at all on the screen says how to move between panes or how to reach the second command + // line. Where you are outlives everything. + (string Text, int Rank)?[] ordered = + { + panes ? ("⌃←→↑↓ pane", 3) : null, + tabs ? ("⌃N tab", 1) : null, + panes ? ("⌥⇧←→↑↓ size", 0) : null, + + // ⌃↑↓ is only worth naming where the pane arrows have not already said it. + bars ? (panes ? "⇥ line" : "⇥ · ⌃↑↓ line", 2) : null, }; + + var segments = ordered.OfType<(string Text, int Rank)>().ToList(); + var candidates = new List(segments.Count); + while (segments.Count > 0) + { + candidates.Add($"[dim]{string.Join(" · ", segments.Select(s => s.Text))}[/]"); + segments.Remove(segments.MinBy(s => s.Rank)); + } + + return candidates.ToArray(); } /// diff --git a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs index 14d67e9..d77b69d 100644 --- a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs @@ -48,6 +48,21 @@ public async Task StatefulCommands_ReadCurrentValue() await Assert.That(loggingOn.Any(c => c.Title == "Resume scrollback")).IsTrue(); } + /// + /// The tab cycle is listed, and it is listed on a workspace whose panes each hold one tab — the same + /// rule the directional pane entries follow, because this surface is where a reader learns that a pane + /// holds tabs at all. The chord it names is the one that runs it. + /// + [Test] + public async Task TheTabCycleIsListedWithItsChord() + { + var catalog = CommandCatalog.Build(new Workspace(), Characters, null, new CommandContext()); + + var entry = catalog.Single(c => c.Id == "layout:next-tab"); + await Assert.That(entry.Title).IsEqualTo("Focus the next tab"); + await Assert.That(entry.Subtitle).IsEqualTo("⌃N"); + } + /// /// The numbered pane entries: one per pane that exists, in Panes order (which is the order the /// move overlay badges them in), and only when there is more than one pane. The first nine carry diff --git a/tests/SharpMUTerm.Tui.Tests/TabCycleTests.cs b/tests/SharpMUTerm.Tui.Tests/TabCycleTests.cs new file mode 100644 index 0000000..d100000 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/TabCycleTests.cs @@ -0,0 +1,180 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// ⌃N, and the three places it is now readable from. The chord itself is not new — it has cycled the +/// focused pane's tabs for as long as panes have held more than one window — but it was named on F4 and +/// nowhere else, which is the state ⌃L's newline sat in until it was reported missing. +/// +/// The refusal is the part that is genuinely new behaviour. Advertising a key on a surface obliges that +/// key to do something or say why not: every directional pane entry beside it refuses out loud, and this +/// one returned silently on a pane holding one tab. +/// +/// +/// Serialised: rendering redirects the process-global Console.Out. +[NotInParallel] +public class TabCycleTests +{ + private const int Width = 120; + private const int Height = 32; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo CtrlN => + new('\0', ConsoleKey.N, shift: false, alt: false, control: true); + + private static SharpMUTermApp Demo() + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height)); + } + + /// + /// A fresh client: one pane holding one window. Not the demo scene, whose main pane already carries + /// the Chat capture as a second tab — which is the whole reason the chord had somewhere to go in every + /// frame anyone had looked at, and the silent refusal went unnoticed. + /// + private static SharpMUTermApp OneTab() + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp( + new SharpMUTerm.Core.Configuration.AppConfiguration(), + Headless, + new HeadlessConsoleDriver(Width, Height)); + } + + /// + /// An app whose clock a test can move past . The scenes + /// that put two tabs in front get there by switching character, and that raises a notice which sits + /// over the resting row — so a test reading the row's own content has to let it retire + /// rather than assert against the message that displaced it. + /// + private static (SharpMUTermApp App, ManualTimeProvider Clock) TimedDemo(int width = Width, int height = Height) + { + Console.SetIn(TextReader.Null); + var clock = new ManualTimeProvider(); + return (new SharpMUTermApp( + DemoScene.Build(), Headless, new HeadlessConsoleDriver(width, height), time: clock), clock); + } + + /// + /// tint-tabs is the one view where the focused pane holds two windows as tabs — every other + /// scene with two tabs puts them in a pane that does not hold the focus, and the cycle acts on the + /// focused one. + /// + private static SharpMUTermApp TwoTabsInFront() + { + var app = Demo(); + app.RenderSnapshot("tint-tabs"); + return app; + } + + [Test] + public async Task CtrlNMovesToTheNextTabOfTheFocusedPane() + { + var app = TwoTabsInFront(); + var before = app.ActiveWindowId(); + + app.SimulateKey(CtrlN); + + await Assert.That(app.ActiveWindowId()).IsNotEqualTo(before); + } + + /// + /// Wrapping, which is what makes one key enough: pressed round the strip it comes back rather than + /// stopping at the end. It is also why there is no backward chord to look for. The count is + /// discovered rather than written down — the demo pane holds however many windows the scene left in + /// it, and a literal here would be a test asserting on the fixture instead of on the cycle. + /// + [Test] + public async Task TheCycleVisitsEveryTabAndWrapsBackToWhereItStarted() + { + var app = TwoTabsInFront(); + var first = app.ActiveWindowId(); + + var visited = new List { first }; + for (var press = 0; press < 10; press++) + { + app.SimulateKey(CtrlN); + if (app.ActiveWindowId() == first) + { + break; + } + + visited.Add(app.ActiveWindowId()); + } + + await Assert.That(visited.Distinct().Count()).IsEqualTo(visited.Count).Because("no tab is visited twice"); + await Assert.That(visited.Count).IsGreaterThan(1); + await Assert.That(app.ActiveWindowId()).IsEqualTo(first); + } + + /// + /// The new behaviour. A pane holding one tab has nowhere to cycle to, and the chord said nothing at + /// all — which is exactly what a key that is broken looks like, and is not something the ⌃P surface + /// may list without an answer. + /// + [Test] + public async Task CtrlNOnAPaneHoldingOneTabRefusesOutLoud() + { + var app = OneTab(); + app.RenderSnapshot(); + + app.SimulateKey(CtrlN); + + await Assert.That(app.StatusMarkup).Contains("this pane has one tab"); + } + + /// The ⌃P entry and the chord are one action, so they must leave the same tab in front. + [Test] + public async Task TheCommandSurfaceEntryDoesWhatTheChordDoes() + { + var viaKey = TwoTabsInFront(); + viaKey.SimulateKey(CtrlN); + + var viaEntry = TwoTabsInFront(); + await Assert.That(viaEntry.DispatchCommand("layout:next-tab")).IsTrue(); + + await Assert.That(viaEntry.ActiveWindowId()).IsEqualTo(viaKey.ActiveWindowId()); + } + + /// + /// The status row names the chord exactly while the focused pane has somewhere to cycle to — the same + /// contextual rule the pane and second-bar hints beside it follow, and the reason a fresh client's row + /// is not carrying a key that would only refuse. + /// + [Test] + public async Task TheStatusRowNamesTheChordOnlyWhileThereAreTabsToCycle() + { + var (tabs, clock) = TimedDemo(); + tabs.RenderSnapshot("tint-tabs"); + clock.Advance(SharpMUTermApp.NoticeDuration); // the character switch's notice retires off the row + await Assert.That(tabs.StatusMarkup).Contains("⌃N tab"); + + var solo = OneTab(); + solo.RenderSnapshot(); + await Assert.That(solo.StatusMarkup).DoesNotContain("⌃N tab"); + } + + /// + /// The hint is a segment of a sticky row, and a row that overflows wraps and costs every pane + /// a line of output — which per-pane NAWS then re-announces to every connected server. The tab segment + /// must therefore give way on a narrow terminal like the resize hint does, and the pane hint must + /// survive both of them going. + /// + [Test] + public async Task ANarrowTerminalDropsTheTabHintBeforeTheOneThatSaysWhereYouAre() + { + var narrow = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(76, 30)); + narrow.RenderSnapshot("split"); + + await Assert.That(narrow.StatusMarkup).Contains("⌃←→↑↓ pane"); + foreach (var row in FrameGrid.Decode(narrow.RenderSnapshot("split"), 76, 30)) + { + await Assert.That(row.TrimEnd().Length).IsLessThanOrEqualTo(76); + } + } +} From d10a94fdd053d5338c21984960abb6dfb91863cd Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 13 Aug 2026 23:01:24 -0500 Subject: [PATCH 3/3] docs: FocusHints' doc names the tab segment and its rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph stated the reading order as pane · size · line and explained only size's priority, while the code inserts ⌃N tab between the pane and size segments at rank 1. CLAUDE.md carried the reasoning; the doc a reader of the method actually sees did not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 639e1dd..d6f7d1b 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -10591,9 +10591,15 @@ private string HeaderMarkup() /// /// /// Reading order and drop order are separate, and have to be. The row reads - /// pane · size · line — the two pane chords together, then the bars — while size is the - /// first thing given up. A generator that dropped from the end of the reading order would have to put - /// size last, which reorders a row nobody asked to have reordered. + /// pane · tab · size · line — the pane chords together, then the bars — while size is + /// the first thing given up. A generator that dropped from the end of the reading order would have to + /// put size last, which reorders a row nobody asked to have reordered. + /// + /// + /// ⌃N tab is surrendered second, and that is the one ranking here worth arguing. A pane's + /// tabs are drawn as a strip the reader can see, so this hint names a shortcut to something already on + /// the screen — while nothing at all says how to reach another pane or the second command line. Where + /// you are outlives everything. /// /// private string[] FocusHints()