Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ public static IReadOnlyList<CommandItem> 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
Expand Down
7 changes: 6 additions & 1 deletion src/SharpMUTerm.Tui/MacroKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/SharpMUTerm.Tui/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 91 additions & 20 deletions src/SharpMUTerm.Tui/SharpMUTermApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3642,9 +3642,26 @@ private void ToggleComposer()
?? (_workspace.FindWindow(windowId)?.SessionKey is { Length: > 0 } owner ? owner : null);

/// <summary>
/// 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 <em>is</em>. 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 <em>is</em>. The window closes on
/// success and keeps the post on a refusal.
/// <para>
/// It reaches the session directly rather than through <see cref="OnCommandEntered"/>, and so records
/// the history entry itself. That seam is the command <em>line's</em>: it clears that window's bar
/// draft, moves the unsent-input marker and owns the <c>/web</c>, <c>/graphics</c> and <c>/triggers</c>
/// 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 <c>SendUserInputAsync</c>, 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.
/// </para>
/// <para>
/// The <em>built</em> line is what is kept, not <see cref="ComposeResult.Body"/>: history holds
/// sendable commands, and a recalled entry lands on a one-command bar the raw multi-line buffer would
/// not fit. Through <see cref="InputHistory.Add"/> like every other entry, so a post carrying a connect
/// line meets the same secret gate — that gate lives inside <c>Add</c> 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.
/// </para>
/// </summary>
private void SendComposed(ComposeResult result)
{
Expand All @@ -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);
}

Expand Down Expand Up @@ -6739,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.
Expand Down Expand Up @@ -8301,13 +8322,33 @@ private void RebuildPaneArea()
/// <summary>The TabControl of the focused pane, or null if none is realised.</summary>
private TabControl? FocusedTabs() => _paneTabs.GetValueOrDefault(_workspace.Layout.FocusedPaneId);

/// <summary>Cycles to the next window tab in the focused pane, wrapping (⌃N).</summary>
/// <summary>
/// Why ⌃N can refuse. Named beside the pane cycle's own wording (<see cref="PrefixPanel.NoCycleRefusal"/>)
/// 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.
/// </summary>
private const string NoTabCycleRefusal = "nowhere to cycle to — this pane has one tab";

/// <summary>
/// Cycles to the next window tab in the focused pane, wrapping (⌃N, and ⌃P ▸ <c>Focus the next tab</c>).
/// <para>
/// A pane holding a single tab is refused <em>out loud</em>. 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.
/// </para>
/// </summary>
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;
}

/// <summary>
Expand Down Expand Up @@ -10543,26 +10584,56 @@ 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 <c>--help</c> either way.
/// </para>
/// <para>
/// <b>The ladder is generated, not written out per combination.</b> 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.
/// </para>
/// <para>
/// <b>Reading order and drop order are separate, and have to be.</b> The row reads
/// <c>pane · tab · size · line</c> — the pane chords together, then the bars — while <em>size</em> 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.
/// </para>
/// <para>
/// <b><c>⌃N tab</c> is surrendered second</b>, 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.
/// </para>
/// </summary>
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<string>(),
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<string>(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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// <summary>
Expand Down
15 changes: 15 additions & 0 deletions tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ public async Task StatefulCommands_ReadCurrentValue()
await Assert.That(loggingOn.Any(c => c.Title == "Resume scrollback")).IsTrue();
}

/// <summary>
/// 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.
/// </summary>
[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");
}

/// <summary>
/// The numbered pane entries: one per pane that exists, in <c>Panes</c> order (which is the order the
/// move overlay badges them in), and only when there is more than one pane. The first nine carry
Expand Down
30 changes: 30 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,36 @@ public async Task AltLFlipsTheEscapingAndChangesWhatIsSent()
await Assert.That(world.Telnet.Lines).IsEquivalentTo(new[] { "100%% \\[sure\\]" });
}

/// <summary>
/// A composed post is recallable afterwards, like anything else the user wrote and sent. It is the
/// <em>built</em> 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.
/// </summary>
[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");
}

/// <summary>A post that was refused is a post the user still has; nothing was sent, so nothing is recalled.</summary>
[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()
{
Expand Down
Loading
Loading