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
134 changes: 98 additions & 36 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ than the one currently focused, and `✕` on the active tab only. Tabs keep natu
strip scrolls horizontally when they overflow, with a `»N` counter on the right. Right of that:
`▯▯` split-right, `⌸` split-down, `⤢` zoom.

Below the strip, spawn windows show their capture pattern as a dim line: `⇱ capture ^\[public\]`.
Spawn windows used to show their capture pattern under the strip as a dim `⇱ capture ^\[public\]`
line. **They no longer do** — it was asked for and removed, and a spawn window now renders exactly
like any other output window. Which rule feeds a pane is F2's answer, not the pane's.

Output view: timestamp column (optional), then styled spans. Trigger-highlighted lines get a
2-col left rule in the trigger's colour plus a tinted background.
Expand Down
30 changes: 11 additions & 19 deletions src/SharpMUTerm.Core/Automation/TriggerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,14 @@ namespace SharpMUTerm.Core.Automation;
/// <summary>A script callback requested by a matched trigger, with its capture groups.</summary>
public sealed record TriggerScriptInvocation(string Callback, Match Match);

/// <summary>
/// One line's route to a spawn window: the window's resolved name, and the pattern of the rule that
/// sent it there.
/// <para>
/// The pattern rides along because the destination is no longer enough to identify the rule. A route
/// of <c>Channel $1</c> resolves to <c>Channel Public</c>, <c>Channel Newbie</c> and so on, so a
/// consumer that wanted to know "which rule feeds this pane" and looked the rule up by comparing its
/// <see cref="TriggerActions.SpawnTarget"/> to the window's name would find nothing for every dynamic
/// pane. Carrying it costs one reference and removes the lookup.
/// </para>
/// </summary>
public sealed record SpawnRoute(string Target, string Pattern);

/// <summary>The outcome of running the trigger engine over one output line.</summary>
public sealed class TriggerResult
{
public TriggerResult(
StyledLine line,
bool suppress,
IReadOnlyList<string> responses,
IReadOnlyList<SpawnRoute> spawnTargets,
IReadOnlyList<string> spawnTargets,
IReadOnlyList<TriggerScriptInvocation> scriptInvocations,
IReadOnlyList<Trigger> matched)
{
Expand All @@ -47,8 +34,13 @@ public TriggerResult(
/// <summary>Commands to send back to the server, in order.</summary>
public IReadOnlyList<string> Responses { get; }

/// <summary>The spawn windows this line should be routed to, with the rule that routed it.</summary>
public IReadOnlyList<SpawnRoute> SpawnTargets { get; }
/// <summary>
/// The spawn windows this line should be routed to, each already resolved from its rule's
/// <see cref="TriggerActions.SpawnTarget"/> template with the match's capture groups substituted, so
/// one rule can feed a window per channel. A template that resolves to something that cannot be a
/// window name is refused and simply does not appear here.
/// </summary>
public IReadOnlyList<string> SpawnTargets { get; }

/// <summary>Script callbacks to invoke, with their match data.</summary>
public IReadOnlyList<TriggerScriptInvocation> ScriptInvocations { get; }
Expand Down Expand Up @@ -226,7 +218,7 @@ public TriggerResult Process(StyledLine line)
var current = line;
var suppress = false;
List<string>? responses = null;
List<SpawnRoute>? spawns = null;
List<string>? spawns = null;
List<TriggerScriptInvocation>? scripts = null;
List<Trigger>? matched = null;

Expand Down Expand Up @@ -282,7 +274,7 @@ actions.HighlightBackground is not null ||
if (!string.IsNullOrEmpty(actions.SpawnTarget) &&
ResolveSpawnTarget(actions.SpawnTarget, match) is { } target)
{
(spawns ??= new List<SpawnRoute>()).Add(new SpawnRoute(target, trigger.Pattern));
(spawns ??= new List<string>()).Add(target);
}

if (!string.IsNullOrEmpty(actions.ScriptCallback))
Expand Down Expand Up @@ -313,7 +305,7 @@ actions.HighlightBackground is not null ||
current,
suppress,
(IReadOnlyList<string>?)responses ?? Array.Empty<string>(),
(IReadOnlyList<SpawnRoute>?)spawns ?? Array.Empty<SpawnRoute>(),
(IReadOnlyList<string>?)spawns ?? Array.Empty<string>(),
(IReadOnlyList<TriggerScriptInvocation>?)scripts ?? Array.Empty<TriggerScriptInvocation>(),
(IReadOnlyList<Trigger>?)matched ?? Array.Empty<Trigger>());
}
Expand Down
97 changes: 84 additions & 13 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@
namespace SharpMUTerm.Core.Commands;

/// <summary>A character the command surface can switch to, with enough detail to label the entry.</summary>
public sealed record CharacterRef(string WorldName, string CharacterName, string SessionKey, bool Connected);
/// <param name="Connected">Whether its socket is up.</param>
/// <param name="Open">
/// Whether the client has a session for it in this run — which is a different question from
/// <paramref name="Connected"/>: a character you have switched to and then disconnected is open and
/// offline. It is what <see cref="CommandCatalog.CharacterCycle"/> filters on, because the cycle keys
/// may not <em>create</em> anything.
/// </param>
public sealed record CharacterRef(
string WorldName,
string CharacterName,
string SessionKey,
bool Connected,
bool Open = false);

/// <summary>Live flags the catalog reads so stateful commands show their current value.</summary>
/// <param name="LoggingOn">Whether the focused character is logging.</param>
Expand Down Expand Up @@ -44,6 +56,30 @@ public sealed record SettingsEntry(string Title, string Id, string Shortcut);
/// </summary>
public static class CommandCatalog
{
/// <summary>
/// <b>The characters ⌥J and ⌥K walk, in the order the connection rail draws them.</b> The one
/// definition of the cycle, read by the chord, by the rail's <c>⌥J</c>/<c>⌥K</c> column and by the ⌃P
/// entries' subtitles, so a key and the label that advertises it cannot come to disagree.
/// <para>
/// <b>Only the characters that are already open.</b> Switching to a character the client has never
/// opened <em>creates</em> a session and a window (the shell's <c>SwitchToCharacter</c>), and a cycle
/// key that opened a session per press would dial through a configuration by accident — the user
/// asked for a way to move between the characters they are using, not a way to start all of them.
/// The ones you have not opened are still one click away in the rail and one entry away in ⌃P, and
/// both of those are gestures that <em>mean</em> "open it".
/// </para>
/// <para>
/// Configuration order, not the order they were opened, because that is the order the sidebar lists
/// them in and the sidebar is where the cycle is read off. An order the rail did not draw would make
/// "the row below me" and "the next character" two different things.
/// </para>
/// </summary>
public static List<CharacterRef> CharacterCycle(IReadOnlyList<CharacterRef> characters)
{
ArgumentNullException.ThrowIfNull(characters);
return characters.Where(c => c.Open).ToList();
}

public static IReadOnlyList<CommandItem> Build(
Workspace workspace,
IReadOnlyList<CharacterRef> characters,
Expand All @@ -59,6 +95,19 @@ public static IReadOnlyList<CommandItem> Build(
var activeWindow = workspace.Layout.FocusedPane.ActiveTab;

// GO TO — switch character, then jump to windows.
//
// The two neighbours in the character cycle carry its chords. Only those two: ⌥J and ⌥K move one
// step, so they are the honest answer for the rows either side of you and a lie for anybody
// further away. There is deliberately no direct-selection chord to put on the rest — the digit
// row is spent on windows and panes, and every remaining digit-bearing modifier (⌥⇧, ⌃⇧) has no
// legacy encoding at all on this terminal, which was measured rather than assumed.
var cycle = CharacterCycle(characters);
var here = cycle.FindIndex(c => c.SessionKey == focusedSessionKey);
var next = here >= 0 && cycle.Count > 1 ? cycle[(here + 1) % cycle.Count].SessionKey : null;
var previous = here >= 0 && cycle.Count > 1
? cycle[(here - 1 + cycle.Count) % cycle.Count].SessionKey
: null;

foreach (var character in characters)
{
if (character.SessionKey == focusedSessionKey)
Expand All @@ -67,11 +116,27 @@ public static IReadOnlyList<CommandItem> Build(
}

var state = character.Connected ? "connected" : "offline";
var chord = character.SessionKey == next ? "⌥J · "
: character.SessionKey == previous ? "⌥K · "
: string.Empty;
items.Add(new CommandItem(
CommandGroup.GoTo,
$"Switch to {character.CharacterName}",
CommandIds.Character(character.SessionKey),
$"{character.WorldName} · {state}"));
$"{chord}{character.WorldName} · {state}"));
}

// The chord each window's entry names, from the one place windows are numbered — the *focused*
// character's list, because that is what ⌥N indexes. A window belonging to somebody else has no
// chord from here and correctly gets none: pressing ⌥2 would reach the focused character's second
// window, not this entry, and an entry naming a key that goes elsewhere is the defect the
// numbering exists to prevent. Built as a lookup rather than read per entry because the entries
// walk the whole registry, including windows no pane holds.
var windowOrdinals = new Dictionary<string, int>(StringComparer.Ordinal);
var reachable = workspace.WindowsFor(focusedSessionKey);
for (var i = 0; i < reachable.Count && i < CommandIds.WindowJumpDigits; i++)
{
windowOrdinals[reachable[i].Id] = i + 1;
}

foreach (var window in workspace.Windows)
Expand All @@ -83,18 +148,24 @@ public static IReadOnlyList<CommandItem> Build(

var owner = window.SessionKey ?? "unowned";
var unread = window.Unread > 0 ? $" · {window.Unread} unread" : string.Empty;

// The chord leads, because it is the part a reader is here to learn — the owner and the count
// describe the window and this says how to reach it without the surface at all. Windows past
// the ninth get the same subtitle minus the chord, rather than one naming a key that would do
// something else.
var chord = windowOrdinals.TryGetValue(window.Id, out var ordinal) ? $"⌥{ordinal} · " : string.Empty;
items.Add(new CommandItem(
CommandGroup.GoTo,
$"Go to {window.Title}",
CommandIds.Window(window.Id),
$"{owner}{unread}"));
$"{chord}{owner}{unread}"));
}

// WORLD
// Both carry their chord. The surface is where this client is discovered from, and a key nobody
// can find is the same as no feature — ⌃L's newline sat unused until it was reported missing.
items.Add(new CommandItem(CommandGroup.World, "Reconnect", "world:reconnect", "Alt+R"));
items.Add(new CommandItem(CommandGroup.World, "Disconnect", "world:disconnect", "D"));
items.Add(new CommandItem(CommandGroup.World, "Reconnect", "world:reconnect", "R"));
items.Add(new CommandItem(CommandGroup.World, "Disconnect", "world:disconnect", "D"));

// TERMINAL — stateful labels.
items.Add(context.LoggingOn
Expand Down Expand Up @@ -175,14 +246,14 @@ public static IReadOnlyList<CommandItem> Build(

// 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 rail already numbers the panes the same way in its hosting column, so the entry
// and the label a user is reading off the sidebar are the same number; that is the whole point of
// deriving both from Panes order rather than spelling either out.
// way to make. The number is the one the move and drag overlays badge each pane with, so the entry
// and the digit a user is about to press in move mode are the same number.
//
// Only the first nine carry a chord: ⌥0 is not claimed (it stays bindable as a macro, and the
// framework's own Alt+digit handler ignores it), so a tenth pane gets an entry with no subtitle
// rather than one naming a key that does something else. An entry with no chord is the honest
// shape for a place only the mouse, ⌃O and the arrows can reach.
// The chord is ⌃B N and no longer ⌥N: ⌥N names a *window* now, and a pane and a window are
// different destinations that cannot share one key. ⌃B is where the rest of the pane keymap lives.
// Only the first nine carry it, so a tenth pane gets an entry with no subtitle rather than one
// naming a key that does something else — the honest shape for a place only the mouse, ⌃O, the
// arrows and this entry can reach.
var paneCount = workspace.Layout.Panes.Count;
if (paneCount > 1)
{
Expand All @@ -192,7 +263,7 @@ public static IReadOnlyList<CommandItem> Build(
CommandGroup.Layout,
$"Go to pane {n}",
CommandIds.Pane(n),
n <= CommandIds.PaneJumpDigits ? $"{n}" : null));
n <= CommandIds.PaneJumpDigits ? $"⌃B {n}" : null));
}
}

Expand Down
42 changes: 33 additions & 9 deletions src/SharpMUTerm.Core/Commands/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,48 @@ public static class CommandIds
/// <summary>The id that activates the window named by <paramref name="windowId"/>.</summary>
public static string Window(string windowId) => WindowPrefix + windowId;

/// <summary>
/// How many windows have a keyboard chord of their own: ⌥1–⌥9, counting
/// <see cref="SharpMUTerm.Core.Workspaces.Workspace.WindowsFor"/>. Nine because nine is what the
/// digit row spells with one modifier, what the terminal's Alt encoding covers (<c>ESC</c> + a
/// printable digit), and what the framework's own Alt+1–9 window selector claims — leaving one of
/// those digits unclaimed would hand it back to that selector, so all nine are claimed whether or
/// not there is a window behind them. Windows past the ninth are still reachable by ⌃N, the tab
/// strip, the rail and the ⌃P surface; they simply have no chord, and no surface claims otherwise.
/// <para>
/// The chord counts <em>windows</em> because that is what was asked for: "switch not just
/// characters, but captures, etc." — a capture window only ever shared a pane's number when it
/// happened to be that pane's active tab, so under a pane-numbered chord most of them were
/// unreachable.
/// </para>
/// </summary>
public const int WindowJumpDigits = 9;

/// <summary>Prefix of a "go to this numbered pane" id; the remainder is the pane's 1-based number.</summary>
public const string PanePrefix = "layout:pane-";

/// <summary>
/// How many panes have a keyboard chord of their own: ⌥1–⌥9. Nine rather than the five that were
/// asked for because nine is what the digit row spells with one modifier, what the terminal's Alt
/// encoding covers (<c>ESC</c> + a printable digit), and what the framework's own Alt+1–9 window
/// selector claims — leaving one of those digits unclaimed would hand it back to that selector.
/// Panes past the ninth are still reachable by ⌃O, the arrows and the rail; they simply have no
/// chord, and no surface claims otherwise.
/// How many panes have a keyboard chord of their own: ⌃B 1–⌃B 9. On the <em>prefix</em> rather than
/// on Alt because ⌥N now names a window and one chord cannot mean two things. ⌃B is where every other
/// pane command already lives (split, zoom, close, cycle, move), so the ordinal one joining them costs
/// a reader nothing new to learn. Panes past the ninth are reachable by ⌃O, the arrows, the rail and
/// the ⌃P entry.
/// <para>
/// <b>Derived from <see cref="WindowJumpDigits"/> rather than written down again.</b> The two are the
/// same fact — one row of digits, which is what a keyboard has — and the reason they agree is the
/// keyboard rather than a coincidence anybody chose. This said "Nine to match
/// <c>WindowJumpDigits</c>" beside an independent literal <c>9</c>, which is a documented invariant
/// nothing enforced: editing either would have left the comment claiming an agreement that had
/// stopped being true.
/// </para>
/// </summary>
public const int PaneJumpDigits = 9;
public const int PaneJumpDigits = WindowJumpDigits;

/// <summary>
/// The id that focuses the <paramref name="number"/>th pane, counting the way every surface in this
/// client counts panes: <see cref="SharpMUTerm.Core.Workspaces.WorkspaceLayout.Panes"/> order, which is
/// <b>creation</b> order, which is the order the connection rail's <c>pane N</c> column numbers them
/// in. The chord (⌥N), the rail's label and this id are three spellings of one number.
/// <b>creation</b> order. The chord (⌃B N), the move and drag overlays' <c>pane N</c> label and this
/// id are three spellings of one number.
/// <para>
/// It was tree order — left-to-right then top-to-bottom — and that renumbered panes that already
/// existed whenever one was inserted before them, so a number a user had learnt moved without being
Expand Down
15 changes: 10 additions & 5 deletions src/SharpMUTerm.Core/Session/SessionEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ public enum ConnectionState
Faulted,
}

/// <summary>A line routed to a named spawn window by a matching trigger.</summary>
public sealed class SpawnLineEventArgs(string target, string pattern, StyledLine line) : EventArgs
/// <summary>
/// A line routed to a named spawn window by a matching trigger.
/// <para>
/// It carried the routing rule's <c>Pattern</c> as well, for the one consumer that wanted it: the dim
/// <c>⇱ capture …</c> header a spawn pane drew over its output. That header is gone, and with it the
/// only reason the rule's identity ever left the trigger engine — a routed line is a line and where it
/// goes, and nothing downstream needs to know which rule sent it.
/// </para>
/// </summary>
public sealed class SpawnLineEventArgs(string target, StyledLine line) : EventArgs
{
/// <summary>The window's name, with the rule's capture groups already substituted.</summary>
public string Target { get; } = target;

/// <summary>The pattern of the rule that routed the line here — see <c>SpawnRoute</c>.</summary>
public string Pattern { get; } = pattern;

public StyledLine Line { get; } = line;
}

Expand Down
2 changes: 1 addition & 1 deletion src/SharpMUTerm.Core/Session/WorldSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ private void ProcessOutputLine(StyledLine line)

foreach (var target in result.SpawnTargets)
{
SpawnLine?.Invoke(this, new SpawnLineEventArgs(target.Target, target.Pattern, result.Line));
SpawnLine?.Invoke(this, new SpawnLineEventArgs(target, result.Line));
}

foreach (var response in result.Responses)
Expand Down
Loading
Loading