Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4566684
Alt+1–9 go to a numbered pane, and the numbering is the one on screen
HarryCordewener Jul 30, 2026
de769fc
Merge branch 'feat/pane-jump-alt-digit'
HarryCordewener Jul 30, 2026
f64c950
Move mode targets panes by their number, not a second alphabet
HarryCordewener Jul 30, 2026
a306c8f
Take TelnetNegotiationCore 2.6.5
HarryCordewener Jul 30, 2026
228278a
A trigger's route expands captures, so one rule can feed a pane per c…
HarryCordewener Jul 30, 2026
215ceb9
Delete the MSSP parser: TelnetNegotiationCore 2.6.5 reads it correctly
HarryCordewener Jul 30, 2026
3612b64
Number panes by creation, and say in the rail who is in each
HarryCordewener Jul 30, 2026
d748663
Merge branch 'chore/crawler-on-tnc-2.6.5'
HarryCordewener Jul 30, 2026
0c22c61
Merge branch 'feat/pane-numbering-by-creation'
HarryCordewener Jul 30, 2026
59e5683
Reordering a tab moves the strip, not only the model behind it
HarryCordewener Jul 30, 2026
c8965e4
Say the pane in the sidebar as the chord, not as words
HarryCordewener Jul 30, 2026
c13a9f1
Merge branch 'fix/tab-move-bounds'
HarryCordewener Jul 30, 2026
e4f97f1
Every pane comes back, because the log is keyed by window and not by …
HarryCordewener Jul 30, 2026
218bcb0
Merge branch 'feat/restore-log'
HarryCordewener Jul 30, 2026
5d8d9c5
Tint the tab a line arrived in, and cap the count it already carried
HarryCordewener Jul 30, 2026
29d73f1
Merge branch 'feat/tab-activity-indicator'
HarryCordewener Jul 30, 2026
d029815
Stop the brief claiming the encoding seed reaches MSSP, because it do…
HarryCordewener Jul 30, 2026
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
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ secrets.json
# A user's own configuration and session data must never be committable. None of these are
# written inside the repository today — config.json and the diagnostics log live beside each
# other under the per-user config directory, session transcripts go wherever a character's
# logging settings point, and the scrollback spill is an ephemeral cache under XDG_CACHE_HOME.
# logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and
# the restore log is a `restore/` directory beside config.json holding one bounded file per pane.
# They are listed because a character's password is persisted in config.json in plaintext, so
# the one realistic route into git is a human copying a real config in to reproduce something.
config.json
client-diagnostics-*.log
*.log

# The restore log's own directory. Its files are already covered by the `*.log` above — that is why
# they carry that extension — but the directory is named too, because "why is this ignored" should be
# answerable by reading this file rather than by knowing which extension the format happened to pick.
restore/

Comment on lines +63 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Ignore secrets.json and correct the storage description.

The current configuration stores plaintext passwords in secrets.json and only a reference in config.json (src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs, src/SharpMUTerm.Tui/Program.cs). This block omits secrets.json, so a normal save can leave credentials unignored and stage them in Git. It also describes restore files as per-pane, while the restore contract is per-window.

Suggested fix
 # logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and
-# the restore log is a `restore/` directory beside config.json holding one bounded file per pane.
-# They are listed because a character's password is persisted in config.json in plaintext, so
-# the one realistic route into git is a human copying a real config in to reproduce something.
+# the restore log is a `restore/` directory beside config.json holding one bounded file per window.
+# config.json contains password references; plaintext credentials live in secrets.json, so both
+# files are ignored to keep a copied configuration from putting credentials into git.
 config.json
+secrets.json
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and
# the restore log is a `restore/` directory beside config.json holding one bounded file per pane.
# They are listed because a character's password is persisted in config.json in plaintext, so
# the one realistic route into git is a human copying a real config in to reproduce something.
config.json
client-diagnostics-*.log
*.log
# The restore log's own directory. Its files are already covered by the `*.log` above — that is why
# they carry that extension — but the directory is named too, because "why is this ignored" should be
# answerable by reading this file rather than by knowing which extension the format happened to pick.
restore/
# logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and
# the restore log is a `restore/` directory beside config.json holding one bounded file per window.
# config.json contains password references; plaintext credentials live in secrets.json, so both
# files are ignored to keep a copied configuration from putting credentials into git.
config.json
secrets.json
client-diagnostics-*.log
*.log
# The restore log's own directory. Its files are already covered by the `*.log` above — that is why
# they carry that extension — but the directory is named too, because "why is this ignored" should be
# answerable by reading this file rather than by knowing which extension the format happened to pick.
restore/
🤖 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 @.gitignore around lines 63 - 75, Update the ignore rules and comments near
config.json to include secrets.json, ensuring plaintext credentials cannot be
staged in Git. Correct the restore log description from per-pane to per-window
while preserving the existing restore/ and *.log ignore entries.

# Python bytecode cache
__pycache__/
*.pyc
145 changes: 137 additions & 8 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<PackageVersion Include="SharpConsoleUI" Version="2.5.14" />

<!-- Telnet negotiation: TELOPT/NAWS/MTTS/EOR/CHARSET/MSSP/GMCP/MSDP/MCCP/MXP negotiation. -->
<PackageVersion Include="TelnetNegotiationCore" Version="2.6.0" />
<PackageVersion Include="TelnetNegotiationCore" Version="2.6.5" />

<!-- Sandboxed Lua scripting (pure-managed, no native deps). -->
<PackageVersion Include="MoonSharp" Version="2.0.0" />
Expand Down
7 changes: 4 additions & 3 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,10 +374,11 @@ claimed chord cancels a pending prefix before opening its own surface.
### Move mode (`⌃B m`) — the keyboard path for window placement

Drag is an accelerator, not the only route. Move mode: the active window lifts, every pane dims
and shows a large target letter (`a`–`j`), and the status bar becomes the prompt
`MOVE #public → [b] split right · a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel`.
and shows its own number (`1`–`9`, the same ordinal the sidebar labels it with and `⌥N` jumps to),
and the status bar becomes the prompt
`MOVE #public → split pane 2 right · 1–9 pane · ←↑↓→ edge · ⏎ commit · Esc cancel`.

- `a`–`j` or Tab picks the destination pane
- `1`–`9` or Tab picks the destination pane
- arrows or `hjkl` toggle an edge (splits there instead of adding as a tab); pressing the same
edge again clears it
- ⏎ commits, Esc cancels
Expand Down
2 changes: 1 addition & 1 deletion docs/design/SharpMUTerm-TUI-v3.dc.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
<span style="color:#8b7a4e">→</span>
<span style="color:#e5c07b;font-weight:700">[{{ moveTargetLetter }}]</span>
<span style="color:#c8bd9a">{{ moveEdgeLabel }}</span>
<span style="color:#8b7a4e;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis">· a–j pane · ←↑↓→ edge</span>
<span style="color:#8b7a4e;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis">· 1–9 pane · ←↑↓→ edge</span>
<div style="flex:1 1 auto;min-width:6px"></div>
<span style="color:#e5c07b;flex:0 0 auto;white-space:nowrap">⏎ commit</span>
<span style="color:#8b7a4e;flex:0 0 auto;white-space:nowrap">Esc cancel</span>
Expand Down
100 changes: 93 additions & 7 deletions src/SharpMUTerm.Core/Automation/TriggerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ 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<string> spawnTargets,
IReadOnlyList<SpawnRoute> spawnTargets,
IReadOnlyList<TriggerScriptInvocation> scriptInvocations,
IReadOnlyList<Trigger> matched)
{
Expand All @@ -34,8 +47,8 @@ public TriggerResult(
/// <summary>Commands to send back to the server, in order.</summary>
public IReadOnlyList<string> Responses { get; }

/// <summary>Named spawn windows this line should be routed to.</summary>
public IReadOnlyList<string> SpawnTargets { get; }
/// <summary>The spawn windows this line should be routed to, with the rule that routed it.</summary>
public IReadOnlyList<SpawnRoute> SpawnTargets { get; }

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

Expand Down Expand Up @@ -266,9 +279,10 @@ actions.HighlightBackground is not null ||
(responses ??= new List<string>()).Add(match.Result(actions.SendResponse));
}

if (!string.IsNullOrEmpty(actions.SpawnTarget))
if (!string.IsNullOrEmpty(actions.SpawnTarget) &&
ResolveSpawnTarget(actions.SpawnTarget, match) is { } target)
{
(spawns ??= new List<string>()).Add(actions.SpawnTarget);
(spawns ??= new List<SpawnRoute>()).Add(new SpawnRoute(target, trigger.Pattern));
}

if (!string.IsNullOrEmpty(actions.ScriptCallback))
Expand Down Expand Up @@ -299,11 +313,83 @@ actions.HighlightBackground is not null ||
current,
suppress,
(IReadOnlyList<string>?)responses ?? Array.Empty<string>(),
(IReadOnlyList<string>?)spawns ?? Array.Empty<string>(),
(IReadOnlyList<SpawnRoute>?)spawns ?? Array.Empty<SpawnRoute>(),
(IReadOnlyList<TriggerScriptInvocation>?)scripts ?? Array.Empty<TriggerScriptInvocation>(),
(IReadOnlyList<Trigger>?)matched ?? Array.Empty<Trigger>());
}

/// <summary>
/// The window a matched rule routes to: its <see cref="TriggerActions.SpawnTarget"/> with capture
/// groups substituted, or null when what came out cannot be a window name.
/// <para>
/// Rewrite and respond have always expanded <c>$1</c>; the route did not, so one rule could only ever
/// feed one statically-named pane. <c>^&lt;(.+?)&gt;</c> routing to <c>Channel $1</c> is the case this
/// exists for: one rule, a pane per channel, each created on the channel's first line.
/// </para>
/// <para>
/// <b>Why this is guarded and the other two are not.</b> A rewrite's output is text on a line and a
/// response's output is a command the user's own rule chose to send. This one becomes a
/// <em>durable named object</em> — a window id, a tab title, a row in the sidebar — built out of
/// whatever the server put between the brackets. So a resolved name is trimmed and then refused if it
/// is empty, carries a control character (a name holding a stray escape or newline would corrupt every
/// surface that draws it), or runs past <see cref="MaxTargetLength"/>. Refusing means the line is not
/// routed; it still prints to the main window unless the rule also gags it, which is the quiet failure
/// rather than a pane named after a screenful of garbage.
/// </para>
/// <para>
/// There is deliberately <b>no ceiling on how many panes a rule may create</b>. A pattern that captures
/// more loosely than its author meant will open a pane per variant, and that is the author's to fix by
/// tightening the pattern — a client-imposed cap would silently drop the channel you cared about.
/// </para>
/// </summary>
private static string? ResolveSpawnTarget(string template, Match match)
{
string resolved;
try
{
resolved = match.Result(template);
}
catch (FormatException)
{
// A template Regex.Result cannot parse at all. The rule is malformed rather than the line, so
// routing it anywhere would be a guess.
return null;
}

resolved = resolved.Trim();

if (resolved.Length == 0 || resolved.Length > MaxTargetLength)
{
return null;
}

for (var i = 0; i < resolved.Length; i++)
{
if (char.IsControl(resolved[i]))
{
return null;
}

// A group reference that survived expansion: Regex.Result leaves "$3" as the two characters
// "$3" when the pattern has no third group, rather than throwing, so without this a typo in
// the template opens a pane literally named "$3" and keeps feeding it. Refusing is the honest
// reading — the rule asked for something the pattern cannot give it.
if (resolved[i] == '$' && i + 1 < resolved.Length && char.IsAsciiDigit(resolved[i + 1]))
{
return null;
}
}

return resolved;
}

/// <summary>
/// How long a resolved window name may be. A tab strip and the sidebar both draw it, and a capture is
/// only bounded by what the server sent — this is the point past which a "name" is really a line of
/// output that matched too much.
/// </summary>
public const int MaxTargetLength = 64;

private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions)
{
var restyled = StyledText.Restyle(line, match.Index, match.Length, style =>
Expand Down
35 changes: 35 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ public static IReadOnlyList<CommandItem> Build(
CommandGroup.Terminal, "Back to live output", "term:scroll-live", "⌃End"));
}

// The restore log's one control that is not a settings field. It is listed unconditionally, and
// it is listed *here* rather than only on F9, because "delete what this client has written down
// about my session" is a thing a person wants to do now — after saying something they would
// rather was not on disk — and a purge you have to find in a settings screen is a purge that
// happens tomorrow. The per-character switch on F9 is the other half: this one is immediate and
// total, that one is a standing preference.
items.Add(new CommandItem(
CommandGroup.Terminal,
"Purge the restore log",
"term:restore-purge",
"deletes every pane's saved content"));

// The client's own messages — the status-line notices that dismiss themselves — kept out of the
// output window (and so out of the session log) and readable here instead.
items.Add(new CommandItem(
Expand Down Expand Up @@ -161,6 +173,29 @@ 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"));

// 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.
//
// 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.
var paneCount = workspace.Layout.Panes.Count;
if (paneCount > 1)
{
for (var n = 1; n <= paneCount; n++)
{
items.Add(new CommandItem(
CommandGroup.Layout,
$"Go to pane {n}",
CommandIds.Pane(n),
n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null));
}
}

// Pane size, in the plain words the request used ("increase/decrease a pane's horizontal or
// vertical character size") rather than in the chord's terms. Listed for the same reason the
// directional entries and the newline chord are: ⌥⇧+arrow is not a chord anybody guesses, and a
Expand Down
27 changes: 27 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,31 @@ 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>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.
/// </summary>
public const int PaneJumpDigits = 9;

/// <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.
/// <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
/// touched. Creation order is stable while a pane is open, and closing one compacts the rest so the
/// range stays contiguous.
/// </para>
/// </summary>
public static string Pane(int number) => PanePrefix + number.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
14 changes: 13 additions & 1 deletion src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public sealed class AppConfiguration
/// </summary>
public ScrollbackSpillOptions ScrollbackSpill { get; set; } = new();

/// <summary>
/// How much of each pane's recent content is kept so that restarting the client refills the panes it
/// came from. Unlike <see cref="ScrollbackSpill"/> this is <em>meant</em> to survive — it is the one
/// thing in <see cref="LastSession"/>'s neighbourhood that persists text rather than structure — and
/// unlike the session transcripts it is on by default and read only by the client itself. See
/// <see cref="SharpMUTerm.Core.Text.RestoreLog"/>.
/// </summary>
public RestoreLogOptions RestoreLog { get; set; } = new();

/// <summary>
/// Forces a graphics protocol regardless of capability detection: one of
/// <c>none</c>, <c>halfblock</c>, <c>sixel</c>, <c>kitty</c>. Null means auto-detect.
Expand Down Expand Up @@ -71,7 +80,10 @@ public sealed class AppConfiguration
/// <summary>
/// The last workspace layout (panes, windows, focus) so the app can resume where it left off.
/// Null on a fresh config; the shell rebuilds a live workspace from it at startup via
/// <see cref="WorkspaceState.Restore"/>. Scrollback is not persisted — only structure.
/// <see cref="WorkspaceState.Restore"/>. Only structure lives here — the <em>content</em> of each
/// window is the restore log's business (<see cref="RestoreLog"/>), keyed by the same window ids
/// this records, and deliberately not in <c>config.json</c>: a hundred thousand lines of chat would
/// make the one file people hand-edit and paste into bug reports unreadable and unshareable.
/// </summary>
public WorkspaceState? LastSession { get; set; }

Expand Down
7 changes: 6 additions & 1 deletion src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ public sealed class CharacterDefinition
OnConnect = OnConnect,
OnDisconnect = OnDisconnect,
TriggerSets = new List<string>(TriggerSets),
Logging = new LoggingSettings { Format = Logging.Format, Directory = Logging.Directory },
Logging = new LoggingSettings
{
Format = Logging.Format,
Directory = Logging.Directory,
RestoreLog = Logging.RestoreLog,
},
};

/// <summary>
Expand Down
19 changes: 19 additions & 0 deletions src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ public sealed class LoggingSettings

/// <summary>Directory for log files. Defaults to a per-session folder under the config dir.</summary>
public string? Directory { get; set; }

/// <summary>
/// Whether this character's panes come back with their previous session's content after a restart
/// (<see cref="SharpMUTerm.Core.Text.RestoreLog"/>). On by default, and the per-character opt-out for
/// somebody who does not want one world's text on disk between sessions.
/// <para>
/// It sits beside <see cref="Format"/> because F9 is where a character's "what of mine is written
/// down" questions are answered, and it is a separate switch from it because the two settings are
/// different things: a transcript is a file <em>you</em> keep, read and choose a format for, and this
/// is a small bounded tail nothing but the client's own startup ever reads. Turning one off has never
/// implied anything about the other.
/// </para>
/// <para>
/// Clearing it stops the writing <em>and</em> drops whatever is already stored for that character's
/// windows on the next launch — an opt-out that left the last session's text lying there would be
/// answering a different question from the one it was asked.
/// </para>
/// </summary>
public bool RestoreLog { get; set; } = true;
}

/// <summary>
Expand Down
6 changes: 5 additions & 1 deletion src/SharpMUTerm.Core/Session/SessionEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ public enum ConnectionState
}

/// <summary>A line routed to a named spawn window by a matching trigger.</summary>
public sealed class SpawnLineEventArgs(string target, StyledLine line) : EventArgs
public sealed class SpawnLineEventArgs(string target, string pattern, 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
Loading
Loading