Skip to content
Merged
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
51 changes: 47 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg
- **Views:** `worlds`/`settings`, `triggers`, `route`, `highlight`, `aliases`, `timers`, `keypad`,
`set`, `textansi`, `input`, `logging`, `password`, `freeze`, `spawn`, `split`, `move`, `drag`,
`history`, `history-search`, `history-search-filter`, `draft`, `draft2`, `menu`, `menu-split`,
`messages`, `quit`, `deletions`, `web`, `scrollback`, `scrollback-up`, `freeze-scrollback`, plus the
`messages`, `quit`, `deletions`, `web`, `scrollback`, `scrollback-up`, `freeze-scrollback`,
`focus`/`focus-moved` (a split *and* a second command line — the one geometry showing a focused pane
beside an unfocused one and an armed bar above an idle one, before and after a real ⌃→), plus the
default workspace
(no `--view`). Any settings screen also takes a `-edit` suffix, which opens it and drives real
keys in so the frame shows a field mid-edit. State toggles: `collapsed`, `prefix`, `timestamps`.
Expand Down Expand Up @@ -195,16 +197,57 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/]
- **Only `MarkupControl.AppendLine` and `FeedRange` hand pane content to a control** — the seam a
windowed feed replaces. Appending re-parses the whole control (the parse cache is keyed on a content
version), so never "refresh" a pane by re-`SetContent`-ing the full buffer on a scroll or a frame.
- **Focus is indicated by recolouring what is already drawn — never by spending a cell.** Per-pane NAWS
is derived from the pane rectangle (`PaneOutputRects`), so a border, gutter or marker column that only
the focused pane has would re-announce a different terminal size to every connected server on every
focus change and reflow the game's own output. The cues are the pane's own plane
(`WorkspacePalette.Focus`), the active tab's chip colour (`TabControl.Active*BackgroundColor`), and a
`▌` in the tab *title* — all zero-cost. `FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangle`
is the test that stops this being "improved" into a border. Colours live in `WorkspacePalette`, whose
constants are all derived from a `ScreenPalette` pair so the workspace and the settings screens share
one idea of what focus looks like; the focus step is `CursorBg ÷ EditBg`.
- **The Ctrl+arrows move pane *selection*, not keyboard focus.** The pin
(`FocusChanged → PinFocusToArmedBar`) is untouched: typing always lands in the armed command line
wherever you have navigated to, which is why "move into the pane" needs no third piece of state.
`TryFocusKey` sits in `HandleWindowKey` **after** `DispatchMacro` (so `MacroKeys.Verdict` reporting a
macro on `Ctrl+Left` as live stays true) and **before** `TryScrollKey`/`TryRecallKey` and the command
line (which would otherwise eat them — `TryRecallKey` ignores modifiers). Word movement moved from
`Ctrl+←/→` to `Alt+←/→` to make room. Vertically the panes and the bars are one ladder: ⌃↓ off the
last pane arms the second command line, ⌃↑ leaves it.
- **The scrollback keys are routed from `PreviewKeyPressed`** (`TryScrollKey`), and the wheel from the
driver (`ScrollPaneUnderPointer`), for the same reason everything else in this window is: focus is
pinned to the armed bar, so `ScrollablePanelControl.ProcessKey` — which returns false unless it has
focus — would never see a key.
- **Control chords collapse onto their ASCII bytes, so some are unbindable.** `AnsiInputParser`
decodes no CSI-u and enables no `modifyOtherKeys`: `Ctrl+H` arrives as `Backspace` with
`control: false` (byte 0x08), and I/M/J are Tab/Enter/Enter — the app cannot even tell the modifier
was held, so binding those breaks the plain key instead. `Alt+Backspace` is not available either:
ESC followed by a *control* byte is emitted as **two** keys (Escape, then Backspace), so only
`ESC` + a printable byte becomes an Alt chord. `MacroKeys.Verdict` is the readable form of all this.
was held, so binding those breaks the plain key instead. **`Ctrl+⏎` and `Shift+⏎` are the same
problem and cannot be bound at all**: CR (0x0D) and LF (0x0A) both become a bare `ConsoleKey.Enter`
with no modifier bits. They stay in `InputBarControl`'s key table because the Windows
`Console.ReadKey` path does report them, but **no surface may advertise them** — that is
test-enforced (`AdvertisedKeyHonestyTests`). `MacroKeys.Verdict` is the readable form of all this.
- **ESC + a control byte arrives as two keys, and that is a chord you can reassemble.** Only
`ESC` + a *printable* byte becomes a single Alt chord; `ESC` + a control byte is emitted as
**two** key events (`AnsiInputParser.ProcessEscape`) — which is why `Alt+Backspace` is not
available. **`Alt+⏎` is, though**, and it is the newline chord: `SharpMUTermApp.TryAltEnter` pairs
an Escape with an Enter arriving inside the framework's own `UnixStdinReader.EscTimeoutMs` (50 ms)
and hands the bar a synthetic Alt+Enter. It is safe because Escape in the command line is a genuine
no-op and every other meaning of Escape is handled earlier in `HandleWindowKey`; and it is reliable
because both halves land in *one* read, one parse and one dispatch batch (a terminal writes `ESC CR`
in a single write), so the observed gap is microseconds, not milliseconds. ⌃L is kept as the second
spelling. **Getting `Ctrl+⏎`/`Shift+⏎` properly needs the Kitty keyboard protocol, and that cannot
be done consumer-side** — see below.
- **The input stack cannot be extended from here.** Enabling the Kitty keyboard protocol is trivial
(`IConsoleDriver.WriteClipboardOsc52` is a de-facto public raw-escape emitter, and `Start`/`Stop`
already pair `CSI ?2004h`/`l` for bracketed paste). *Decoding* it is the wall: `AnsiInputParser`,
`UnixStdinReader`, `InputEvent` and `TerminalRawMode` are all `internal`; `NetConsoleDriver` has
**zero** virtual members, a private `WriteOutput`, field-like events a subclass cannot raise, and it
constructs its parser and reader as *locals* inside `Start()`. So enabling reporting without a
matching decoder makes the affected keys **vanish silently** (`DispatchCsi`'s `default:` emits
`UnknownSequenceEvent`, which `UnixStdinReader` drops). Owning input means a from-scratch
`IConsoleDriver` (~900–1400 lines re-authoring internal termios + parser logic). The cheap unblock is
upstream: make `AnsiInputParser`/`InputEvent` public and add an `UnknownSequenceHandler` hook, or add
an input-reader factory to `NetConsoleDriverOptions`. ~15 lines there; do not try it from here.
Comment on lines +230 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== CLAUDE.md around target lines ==\n'
sed -n '220,250p' CLAUDE.md | cat -n

printf '\n== Search for Kitty / modifyOtherKeys / CSI-u mentions ==\n'
rg -n -i 'kitty|modifyOtherKeys|CSI-u|csi-u|xterm' CLAUDE.md . --glob '!**/.git/**'

Repository: SharpMUSH/SharpMUTerm

Length of output: 38530


🌐 Web query:

xterm modifyOtherKeys CSI-u modifier-aware sequences documentation

💡 Result:

xterm's modifyOtherKeys is a resource that allows the terminal to report key combinations that are typically ambiguous or unmappable using standard terminal escape sequences [1]. It addresses the historical limitation where certain modified keys (like Ctrl+I) are indistinguishable from others (like Tab) [1]. Mechanism When enabled, xterm sends "CSI u" encoded sequences for "other" keys—typically printable keys like letters, numbers, and punctuation—when they are modified by keys such as Control, Alt, or Meta [2][3]. The general structure for this encoding is CSI [codepoint]; [modifiers] u [4]. Modes for modifyOtherKeys The behavior is controlled by the modifyOtherKeys resource, which accepts the following values [5]: 0: Disabled [5]. 1: Enabled for most keys, but excludes keys with well-known legacy behaviors (e.g., Tab, Backspace, Esc, and certain X11-defined control sequences like Ctrl+Space or Ctrl+3) [5][3]. 2: Enabled for all keys, including the exceptions listed in mode 1. This mode ignores legacy X11 library special cases and ensures modified keys send distinct sequences [5][3]. 3: (Introduced in xterm patch #398) Extends the reporting to all keys, including unmodified ones, effectively forcing every keypress to send a sequence [6]. Runtime Control (XTMODKEYS) Applications can toggle this behavior at runtime using the XTMODKEYS control sequence [7][8]: CSI > 4; Pv m Where Pv corresponds to the mode (0, 1, 2, or 3) [7][6]. To disable the feature, an application can send CSI > 4; 0 m [7][6]. Relationship to CSI-u and Modern Protocols The "CSI u" encoding was a precursor to more modern keyboard protocols, such as the Kitty keyboard protocol [1][4]. While the Kitty protocol is richer and designed for modern, greenfield applications, modifyOtherKeys and the CSI u encoding remain the de-facto standard for compatibility with many existing terminal user interfaces (TUIs) [7][4]. Official Documentation The definitive technical references for these sequences are: 1. The xterm man page (xterm(1)) [9][5]. 2. The ctlseqs.txt file maintained by the xterm author [8]. 3. The XTerm "Other" Modified Keys FAQ page [2][10].

Citations:


Broaden the input-protocol wording. Ctrl+⏎ / Shift+⏎ need a modifier-preserving terminal protocol plus matching parser support; Kitty is one option, but xterm-style modifyOtherKeys/CSI-u modes are another where supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 230 - 242, Broaden the input-protocol discussion to
state that Ctrl+Enter and Shift+Enter require a modifier-preserving terminal
protocol and corresponding parser support. Present Kitty as one option, while
also mentioning supported xterm-style modifyOtherKeys/CSI-u modes, and avoid
implying Kitty is the only solution.

- **A global shortcut runs before any window**, so a chord in `MacroKeys.AppShortcuts` can never reach
a control's own key table. That is why the command line has no ⌃W (`CloseActiveWindow` claims it) and
why `InputBuffer.KillWordLeft` currently has no chord that can reach it.
Expand Down
29 changes: 23 additions & 6 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,30 @@ public static IReadOnlyList<CommandItem> Build(
: new CommandItem(
CommandGroup.Terminal, "Show second input", "term:input2-on", "this window · ⌃B i"));

// LAYOUT
items.Add(new CommandItem(CommandGroup.Layout, "Split right", "layout:split-right"));
items.Add(new CommandItem(CommandGroup.Layout, "Split down", "layout:split-down"));
// The newline chord. Here for the same reason the scrollback keys are: a chord that works and is
// named nowhere is a chord nobody finds, which is precisely how this one was reported as missing.
// Alt+⏎ is the modifier+Enter this host delivers — a terminal reports Shift+⏎ and Ctrl+⏎ as a
// bare ⏎, so naming those would be advertising a key that cannot fire.
items.Add(new CommandItem(
CommandGroup.Terminal, "Insert a newline in the command line", "term:newline", "Alt+⏎ · ⌃L"));

// LAYOUT — every entry carries the chord that runs it, because this surface is where the client
// is discovered from and the ⌃B keymap is otherwise visible only while the prefix is armed.
items.Add(new CommandItem(CommandGroup.Layout, "Split right", "layout:split-right", "⌃B |"));
items.Add(new CommandItem(CommandGroup.Layout, "Split down", "layout:split-down", "⌃B -"));
items.Add(context.Zoomed
? new CommandItem(CommandGroup.Layout, "Unzoom pane", "layout:unzoom")
: new CommandItem(CommandGroup.Layout, "Zoom pane", "layout:zoom"));
items.Add(new CommandItem(CommandGroup.Layout, "Close pane", "layout:close"));
? new CommandItem(CommandGroup.Layout, "Unzoom pane", "layout:unzoom", "⌃B z")
: new CommandItem(CommandGroup.Layout, "Zoom pane", "layout:zoom", "⌃B z"));
items.Add(new CommandItem(CommandGroup.Layout, "Close pane", "layout:close", "⌃B x"));

// Directional pane focus. Listed whether or not the workspace has a second pane, deliberately:
// this is the surface that teaches the keyboard, and the entries are how a reader learns the
// workspace splits at all. Each refuses out loud when there is nothing that way.
items.Add(new CommandItem(CommandGroup.Layout, "Focus pane left", "layout:focus-left", "⌃←"));
items.Add(new CommandItem(CommandGroup.Layout, "Focus pane right", "layout:focus-right", "⌃→"));
items.Add(new CommandItem(CommandGroup.Layout, "Focus pane up", "layout:focus-up", "⌃↑"));
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"));

// SETTINGS — one entry per configuration screen, in the order the host lists them (its F-key
// order). Every screen or none: a surface offering one of them and hiding the rest would read
Expand Down
11 changes: 10 additions & 1 deletion src/SharpMUTerm.Core/Workspace/PaneCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ public enum PaneCommand
/// <summary>
/// Maps prefix keys to <see cref="PaneCommand"/>s and applies them to a <see cref="WorkspaceLayout"/>.
/// Pure and UI-agnostic: the SharpConsoleUI key handler resolves a key here, then applies the result,
/// keeping the tmux keymap out of the view code and unit-testable.
/// keeping the part of the tmux keymap that is <em>about the layout</em> out of the view code and
/// unit-testable.
/// <para>
/// It is deliberately <b>not</b> the whole ⌃B keymap, and reading it as such is a mistake worth naming:
/// <c>b</c> collapses the connection rail, <c>m</c> arms move mode and <c>i</c> raises the second command
/// line, and none of the three is a change to the split tree, so none can be a <see cref="PaneCommand"/>
/// that <see cref="Apply"/> could perform. Those live in the app's own <c>RunPrefixCommand</c>. The
/// keymap a user actually reads is the armed prefix strip in the header, and it lists all of them; a test
/// checking an advertised ⌃B chord against <em>this</em> resolver would call three live keys unbound.
/// </para>
/// </summary>
public static class PaneCommands
{
Expand Down
139 changes: 139 additions & 0 deletions src/SharpMUTerm.Core/Workspace/PaneNavigation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
namespace SharpMUTerm.Core.Workspaces;

/// <summary>Which way a directional focus move goes.</summary>
public enum PaneDirection
{
/// <summary>Toward smaller X.</summary>
Left,

/// <summary>Toward larger X.</summary>
Right,

/// <summary>Toward smaller Y.</summary>
Up,

/// <summary>Toward larger Y.</summary>
Down,
}

/// <summary>
/// Directional pane focus — which pane is "left of" this one, and so on. It answers from
/// <em>geometry</em> (the solved <see cref="PaneRect"/>s) rather than from the split tree, because the
/// question the user is asking is about what they can see: in a tree of nested splits, "the pane to my
/// left" is a spatial fact, and the same tree walked structurally gives answers that are correct about
/// the tree and wrong about the screen.
/// <para>
/// Pure and UI-agnostic. The caller supplies the rectangles, so the same rule serves the live layout
/// (the arranged pane rectangles, which is what the app passes — they already reflect zoom, since a
/// zoomed workspace realises exactly one pane) and a unit test (a hand-written dictionary, or
/// <see cref="LayoutSolver.Solve"/> over any bounds).
/// </para>
/// </summary>
public static class PaneNavigation
{
/// <summary>
/// The pane <paramref name="direction"/> of <paramref name="fromPaneId"/>, or null when there is
/// none — which the caller is expected to <em>report</em> rather than swallow, because a navigation
/// key that silently does nothing is indistinguishable from one that is not bound.
/// <para>
/// Candidates are the panes that begin beyond the near edge of the starting pane on the axis of
/// travel. Among them the winner is the one that <em>overlaps</em> the starting pane on the cross
/// axis and lies nearest along the axis of travel; overlap is preferred absolutely, so a wide pane
/// directly alongside always beats a nearer one that is merely diagonal. Ties — two panes stacked in
/// the column you are stepping into — go to the one whose cross-axis centre is closest to yours,
/// then to the lower pane id, so the answer is stable frame to frame.
/// </para>
/// </summary>
public static string? Neighbour(
IReadOnlyDictionary<string, PaneRect> rects,
string fromPaneId,
PaneDirection direction)
{
ArgumentNullException.ThrowIfNull(rects);
ArgumentNullException.ThrowIfNull(fromPaneId);

if (!rects.TryGetValue(fromPaneId, out var from))
{
return null;
}

var horizontal = direction is PaneDirection.Left or PaneDirection.Right;
string? best = null;
(bool Diagonal, int Gap, int Cross, string Id) bestScore = default;

foreach (var (id, rect) in rects)
{
if (string.Equals(id, fromPaneId, StringComparison.Ordinal) || rect.IsEmpty)
{
continue;
}

if (Gap(from, rect, direction) is not { } gap)
{
continue;
}

// Overlap on the cross axis is what makes a pane "alongside" rather than "diagonal from".
var diagonal = !Overlaps(from, rect, horizontal);
var cross = Math.Abs(Centre(rect, horizontal) - Centre(from, horizontal));
var score = (diagonal, gap, cross, id);
if (best is null || Better(score, bestScore))
{
best = id;
bestScore = score;
}
}

return best;
}

/// <summary>Whether one candidate beats another: alongside first, then nearest, then most aligned.</summary>
private static bool Better(
(bool Diagonal, int Gap, int Cross, string Id) candidate,
(bool Diagonal, int Gap, int Cross, string Id) incumbent)
{
if (candidate.Diagonal != incumbent.Diagonal)
{
return !candidate.Diagonal;
}

if (candidate.Gap != incumbent.Gap)
{
return candidate.Gap < incumbent.Gap;
}

if (candidate.Cross != incumbent.Cross)
{
return candidate.Cross < incumbent.Cross;
}

return string.CompareOrdinal(candidate.Id, incumbent.Id) < 0;
}

/// <summary>
/// How far <paramref name="candidate"/> lies beyond <paramref name="from"/> in the direction of
/// travel, or null when it does not lie beyond it at all. Measured edge to edge, so the one-cell
/// divider between two siblings scores 1 and a pane across a nested split scores more.
/// </summary>
private static int? Gap(PaneRect from, PaneRect candidate, PaneDirection direction)
{
var gap = direction switch
{
PaneDirection.Left => from.X - (candidate.X + candidate.Width),
PaneDirection.Right => candidate.X - (from.X + from.Width),
PaneDirection.Up => from.Y - (candidate.Y + candidate.Height),
_ => candidate.Y - (from.Y + from.Height),
};

return gap >= 0 ? gap : null;
}

/// <summary>Whether two rectangles share any extent on the axis <em>across</em> the travel.</summary>
private static bool Overlaps(PaneRect a, PaneRect b, bool horizontal) => horizontal
? a.Y < b.Y + b.Height && b.Y < a.Y + a.Height
: a.X < b.X + b.Width && b.X < a.X + a.Width;

/// <summary>A rectangle's centre on the axis across the travel, doubled to stay integral.</summary>
private static int Centre(PaneRect rect, bool horizontal) =>
horizontal ? (rect.Y * 2) + rect.Height : (rect.X * 2) + rect.Width;
}
9 changes: 9 additions & 0 deletions src/SharpMUTerm.Tui/Glyphs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ internal static class Glyphs
public const string Heartbeat = "\uf21e"; // nf-fa-heartbeat — keepalive (was the recycle mark)
public const string World = "\uf0ac"; // nf-fa-globe — world/server accent (paired with the spine)

/// <summary>
/// The focused pane's marker, drawn on the active tab of the pane every workspace key acts on. Box
/// drawing rather than a Nerd Font icon, deliberately: it is the one glyph here whose job is to be
/// legible when nothing else is, so it must not be the character that degrades to a tofu box on a
/// plain font. It is also a left <em>edge</em> — what a focus border would have been, had a pane been
/// able to afford the cell for one without changing the size it reports over NAWS.
/// </summary>
public const string FocusedPane = "▌"; // ▌ left half block

// Powerline separators (solid triangles) for flowing segmented bars.
public const string PowerRight = "\ue0b0"; //
public const string PowerLeft = "\ue0b2"; //
Expand Down
Loading
Loading