Skip to content

Settings screens, keyboard interaction, mouse drag-to-split, and inline images - #7

Merged
HarryCordewener merged 23 commits into
mainfrom
feat/settings-panel-treatment
Jul 29, 2026
Merged

Settings screens, keyboard interaction, mouse drag-to-split, and inline images#7
HarryCordewener merged 23 commits into
mainfrom
feat/settings-panel-treatment

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Jul 28, 2026

Copy link
Copy Markdown
Member

Works through the whole docs/HANDOFF.md backlog. Nine commits, each independently verified.

515 → 764 tests. No existing test was weakened or deleted at any point.

What landed

Backlog item
1 Panel treatment for F2–F9 All eight settings screens
3 Live keyboard interaction ↑↓ select, ⇥ pane, Space toggle, Esc/⏎
5 Mouse drag-to-split All four edges + tab drop
2 Inline graphics <img> renders in the web view

Item 4 is verification-only and needs your terminal.

Bugs found along the way

None of these were the task; all were things the work walked into.

  • F5's column rule never rendered. VerticalRule() built a MarkupControl over an empty line list, which measures to zero and never paints. Baseline frame had 0 cells of the rule colour.
  • VisibleLength stripped [[ instead of counting it as one literal character — contradicting its own doc comment and under-padding any column with escaped brackets.
  • The numpad grid didn't align. Cells were variable-width joined by a fixed gap, so any bound key shunted the rest of its row right. It only looked square because the demo bound one key.
  • Move mode advertised ←↑↓→ edge with no arrow handling behind it — only the tab-drop half was reachable and SplitWithWindow had never been called from the TUI.
  • F9's logging toggle was a silent no-op while disconnected (ActiveLogging() returned a throwaway object).
  • F2/F5 never showed their F-key; F5's action bar said lowercase "cancel".

Notable decisions

  • Hints must not lie. Field editing is not built, so the headers no longer advertise ⏎ edit. A test pins that no screen advertises a key its ScreenModel doesn't offer.
  • Cancel replays an undo log rather than restoring a cloned config — cloning AppConfiguration would drop [JsonIgnore] fields like a character's in-memory password. A toggle snapshots the value, not the boolean, because F9's checkbox is really a LogFormat.
  • Drag wiring lives at the driver, not on a control — forced by the framework: WindowEventDispatcher captures the pressed control and routes later drag frames back to it, so a control-level handler would only ever see the source pane.
  • Images render through the framework, not our encoder — also forced. Our encoders emit escape strings; a compositor owns every cell, Cell has no raw-escape field, and AppendCombiner sanitises escapes. The framework writes U+10EEEE placeholder cells, which is what docs/PLAN.md committed to.
  • SharpConsoleUI builds from source when cloned beside the repo, so it can be read and stepped into instead of decompiled. CI falls through to the NuGet package unchanged; both paths verified.

Negative finding

Sixel cannot work inside the compositor at SharpConsoleUI 2.5.14. IImageRenderer is internal and ResolveRenderer() private, so no back-end can be injected. A Sixel-only terminal degrades to half-blocks, and InlineImagePolicy.Describe() states that reason rather than failing silently. Lifting it needs an upstream PR.

Verification

  • Clean build both reference paths; 0 warnings on the package path (the 2 on the source path are upstream AngleSharp advisories).
  • All 17 snapshot views render. Main-UI frames were compared byte-for-byte against a pre-refactor worktree after the chrome consolidation touched MarkupFormatter/Powerline/RailRenderer: identical.
  • Drag is covered end to end on a headless driver with real press/drag/release frames, parameterised over all four edges, asserting split direction and child ordering.
  • The graphics degradation matrix is exhaustive — every protocol × surface, plus an invariant that no combination ever upgrades past what was detected.

What needs your terminal

Nothing below has been seen by a human:

  1. Mouse drag — press a pane's tab strip and drag. Edge quarter splits (left/right vertical, top/bottom horizontal), middle moves the tab, Esc cancels. Watch whether a plain click still just selects the tab.
  2. Inline images/graphics should report Kitty; /web <page with images> should show them inline.
  3. Keyboard interaction — ↑↓/⇥/Space across F2–F9. Shift+⇥ depends on the terminal emitting CSI Z, which not all do.
  4. The full-width input band across resizes (item 4, the pre-existing verification debt).

🤖 Generated with Claude Code

HarryCordewener and others added 4 commits July 28, 2026 15:32
Decomposes TriggersScreenRenderer into pure blocks (HeaderLine,
FooterLine, RulesColumn, EditorColumn) and adds TriggersScreenView to
compose them into a real control tree: full-width header band with
keyboard hints, two column panels split by a vertical rule, and a
Cancel/Save action bar pinned to the last row instead of floating
mid-screen after the content.

Render(...) still merges the same blocks, so the existing renderer tests
pass untouched.

Also fixes a latent bug in WorldsScreenView: VerticalRule() built a
MarkupControl over an empty line list, which measures to zero and so
never painted its background -- F5's column rule has never actually
rendered. An empty grid with a background covers its arranged area.
Baseline worlds frame contained 0 cells of the rule colour; it now has
22, with the rest of the frame unchanged.

Generalises the settings wiring rather than special-casing a second
screen: SettingsView returns a control factory for every screen, so the
snapshot path needs no per-screen branch as the remaining six convert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both screens decomposed into pure blocks with a *ScreenView composing
them into header band / column panels / pinned action bar. Render(...)
still merges the blocks, so the renderer tests pass untouched.

F4's body splits naturally: the numpad grid has a fixed natural width
and the hotkey list takes the remainder.

Unifies the settings wiring. RegisterSettingsShortcuts and SettingsView
carried identical factories keyed two different ways and had to be
edited in lockstep for every conversion; they now read one
SettingsScreens() table of (Key, Views, Control). The --view name was
the only thing that ever differed, and it is now a Views array -- F5
keeps both "worlds" and "settings". The remaining conversions become a
one-line edit each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decomposed into pure blocks with TimersScreenView composing header band
/ list column / rule / editor column / pinned action bar. Render(...)
still merges the blocks, so TimersScreenRendererTests passes untouched.

Also corrects VisibleLength, which stripped "[[" instead of counting it
as one literal character -- contradicting its own doc comment and
under-padding the list column for any row containing escaped brackets.
No test covers padding, so the suite never caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes backlog item 1 -- all eight settings screens now share the F5
treatment.

The three options screens share one renderer, so they share one view:
OptionsScreenView.Build(title, fkey, rows). They are a single options
list rather than two panes, so the body is one elevated card sized to
its content instead of a column split with a rule -- inventing a
division that isn't there would have been the wrong kind of consistency.

With every screen converted, the deferred consolidation pass follows.
Six copies of the palette and helpers collapse into ScreenPalette,
ScreenChrome, and MarkupText. That last one goes wider than the settings
screens: nine copies of Escape and two independently hand-rolled
VisibleLength scanners across seven files, all now one implementation.
Two divergent width-measuring routines was the duplication most likely
to silently mis-pad a column.

Verified the main UI is untouched by that reach: the default workspace,
menu, split and spawn frames are byte-identical before and after.

SharpMUTermApp.Markup and SettingsOverlay.MarkupPanel are deleted --
with no markup screens left, the adapter has no callers.

Two inconsistencies surfaced by putting the fragments in one place: F2
and F5 never showed their F-key in the header hints, and F5's action bar
said lowercase "cancel". Both now match the other six.

Net -398 lines. 514 tests pass, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

F2–F9 settings screens now use composed panel layouts, shared markup and chrome, keyboard sessions, editable configuration models, and centralized overlay wiring. Pane drag-and-drop and inline web-image rendering add policy selection, decoding, composition, previews, and headless coverage.

Changes

Settings screens and workspace interactions

Layer / File(s) Summary
Shared markup and screen chrome
src/SharpMUTerm.Tui/MarkupText.cs, src/SharpMUTerm.Tui/ScreenChrome.cs, src/SharpMUTerm.Tui/ScreenPalette.cs, src/SharpMUTerm.Tui/*Renderer.cs
Markup escaping, visible-width layout, palette colors, cursor bars, bands, separators, and spacing helpers are centralized.
Screen models and configuration contracts
src/SharpMUTerm.Tui/ScreenModel.cs, src/SharpMUTerm.Tui/ScreenSelection.cs, src/SharpMUTerm.Tui/ScreenFocus.cs, src/SharpMUTerm.Tui/ScreenEdits.cs, src/SharpMUTerm.Core/Configuration/*, src/SharpMUTerm.Core/Automation/*
Toggle bindings, pane row models, cursor state, undo snapshots, Text/Input preferences, and live-writable automation properties were added.
F2–F9 renderer and panel views
src/SharpMUTerm.Tui/{Triggers,Worlds,Aliases,Keypad,Timers,Options}ScreenRenderer.cs, src/SharpMUTerm.Tui/*ScreenView.cs
Settings screens now compose renderer blocks into separated panels, option cards, editor panes, and pinned header/footer bands.
Keyboard sessions and overlay integration
src/SharpMUTerm.Tui/SettingsSession.cs, src/SharpMUTerm.Tui/SettingsOverlay.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs
Key handling now drives selection, pane switching, toggles, redraws, save, cancel, and undo through screen bindings and a shared F2–F9 lookup table.
Pane drag-and-drop workflow
src/SharpMUTerm.Core/Workspace/PaneDrop.cs, src/SharpMUTerm.Tui/PaneDrag*.cs, src/SharpMUTerm.Tui/PaneDropRenderer.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs
Pane tab-strip gestures now snapshot geometry, preview tab or edge drops, apply layout changes, cancel on invalid releases or Escape, and synchronize pane metadata.
Inline web-image pipeline
src/SharpMUTerm.Graphics/InlineImagePolicy.cs, src/SharpMUTerm.Tui/WebImage*.cs, src/SharpMUTerm.Tui/WebViewComposer.cs, src/SharpMUTerm.Web/*
Image capability selection, safe fetching and decoding, cell sizing, placeholder indexing, and text/image block composition were added.
Validation and supporting updates
tests/SharpMUTerm.Tui.Tests/*, tests/SharpMUTerm.Core.Tests/*, tests/SharpMUTerm.Graphics.Tests/*, tests/SharpMUTerm.Web.Tests/*, docs/HANDOFF.md, CLAUDE.md, src/SharpMUTerm.Tui/DemoScene.cs
Tests cover settings behavior, cursor rendering, persistence, keypad alignment, pane drops, drag tracking, inline images, end-to-end layouts, and drop previews; demo mappings and documented test status were updated.

Estimated code review effort: 5 (Critical) | ~100 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main themes of the changeset without being misleading.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

NumpadCell produced a variable-width string -- the unbound placeholder
is one visible character, a command up to ten -- and NumpadRow joined
cells with a fixed three-space gap. So any bound key widened its own
cell and shunted the rest of that row right, which is why the grid only
looked square while nothing was bound.

Each cell is now padded to a fixed visible width ("[N] " plus the
longest command it can hold); the last cell in a row is left unpadded to
avoid trailing whitespace. All three rows now start their cells at
columns 0, 17, 34 regardless of content.

The demo scene bound a single numpad key, which can't show a keypad
doing anything. It now carries a movement layout with commands of
differing lengths, one long enough to be ellipsised -- so the snapshot
actually exercises the grid.

Adds a regression test asserting the three rows share cell offsets.
Confirmed it fails without the padding and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/SharpMUTerm.Tui/OptionsScreenRenderer.cs (1)

91-101: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use PadVisible, not raw PadRight, on the escaped label.

Escape(row.Label) can inflate the raw string length past its rendered width ([[[), so PadRight(LabelWidth) pads against the wrong measurement and would misalign the value column for any label containing a bracket. PadVisible (already imported via using static SharpMUTerm.Tui.MarkupText;) is built for exactly this.

🐛 Proposed fix
-        var label = Escape(row.Label).PadRight(LabelWidth);
+        var label = PadVisible(Escape(row.Label), LabelWidth);
         return $"[dim]{label}[/] {Escape(row.Value ?? string.Empty)}{hint}";
🤖 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 `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs` around lines 91 - 101, The
escaped label in the non-toggle rendering path is padded using raw string
length, misaligning values when the label contains markup-sensitive characters.
In the method containing the row rendering logic, replace the
Escape(row.Label).PadRight(LabelWidth) call with the existing PadVisible helper,
preserving the current escaped label and LabelWidth inputs.
src/SharpMUTerm.Tui/TriggersScreenRenderer.cs (1)

230-232: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hex(TerminalColor) duplicated with WorldsScreenRenderer.
See consolidated comment.

🤖 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 `@src/SharpMUTerm.Tui/TriggersScreenRenderer.cs` around lines 230 - 232, Remove
the duplicated Hex(TerminalColor) implementation from SharpMUTerm.Tui’s
TriggersScreenRenderer and reuse the existing shared or WorldsScreenRenderer
implementation identified by the consolidated comment. Update callers as needed
while preserving RGB formatting and the Accent fallback.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs`:
- Around line 103-108: Replace the rendered-glyph check in IsSection with an
explicit OptionRow discriminator, such as its existing kind or section flag, and
update section-row construction to set that discriminator. Preserve normal
labels beginning with “├ ” as ordinary option rows.

In `@src/SharpMUTerm.Tui/TriggersScreenRenderer.cs`:
- Line 1: Remove the duplicated private Hex(TerminalColor) helpers from
TriggersScreenRenderer and WorldsScreenRenderer, and add one shared
TerminalColor-to-#rrggbb helper in the existing MarkupText or ScreenPalette
utility area. Update both renderers to call the shared helper, preserving the
current Accent fallback and output format.

In `@src/SharpMUTerm.Tui/WorldsScreenRenderer.cs`:
- Around line 260-261: Remove the duplicate Hex(TerminalColor) implementation
from WorldsScreenRenderer and reuse the shared helper established for both
WorldsScreenRenderer and TriggersScreenRenderer. Preserve the existing RGB
formatting and Accent fallback through that consolidated implementation.

---

Outside diff comments:
In `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs`:
- Around line 91-101: The escaped label in the non-toggle rendering path is
padded using raw string length, misaligning values when the label contains
markup-sensitive characters. In the method containing the row rendering logic,
replace the Escape(row.Label).PadRight(LabelWidth) call with the existing
PadVisible helper, preserving the current escaped label and LabelWidth inputs.

In `@src/SharpMUTerm.Tui/TriggersScreenRenderer.cs`:
- Around line 230-232: Remove the duplicated Hex(TerminalColor) implementation
from SharpMUTerm.Tui’s TriggersScreenRenderer and reuse the existing shared or
WorldsScreenRenderer implementation identified by the consolidated comment.
Update callers as needed while preserving RGB formatting and the Accent
fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 71b1608b-4006-4c16-9b23-60017519f42e

📥 Commits

Reviewing files that changed from the base of the PR and between caa0970 and 0198a1d.

📒 Files selected for processing (23)
  • docs/HANDOFF.md
  • src/SharpMUTerm.Tui/AliasesScreenRenderer.cs
  • src/SharpMUTerm.Tui/AliasesScreenView.cs
  • src/SharpMUTerm.Tui/CaptureLineRenderer.cs
  • src/SharpMUTerm.Tui/CommandSurfaceRenderer.cs
  • src/SharpMUTerm.Tui/KeypadScreenRenderer.cs
  • src/SharpMUTerm.Tui/KeypadScreenView.cs
  • src/SharpMUTerm.Tui/MarkupFormatter.cs
  • src/SharpMUTerm.Tui/MarkupText.cs
  • src/SharpMUTerm.Tui/OptionsScreenRenderer.cs
  • src/SharpMUTerm.Tui/OptionsScreenView.cs
  • src/SharpMUTerm.Tui/Powerline.cs
  • src/SharpMUTerm.Tui/RailRenderer.cs
  • src/SharpMUTerm.Tui/ScreenChrome.cs
  • src/SharpMUTerm.Tui/ScreenPalette.cs
  • src/SharpMUTerm.Tui/SettingsOverlay.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • src/SharpMUTerm.Tui/TimersScreenRenderer.cs
  • src/SharpMUTerm.Tui/TimersScreenView.cs
  • src/SharpMUTerm.Tui/TriggersScreenRenderer.cs
  • src/SharpMUTerm.Tui/TriggersScreenView.cs
  • src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
  • src/SharpMUTerm.Tui/WorldsScreenView.cs

Comment on lines +103 to +108
/// <summary>A blank separator carrying no label, value, or toggle.</summary>
private static bool IsSpacer(OptionRow row) =>
row.Label.Length == 0 && row.Value is null && row.Toggle is null;

/// <summary>The F8 "Input &amp; spellcheck" screen body.</summary>
public static List<string> InputSpellcheck()
{
var rows = new List<OptionRow>
{
new("├ INPUT", null, null),
new("local echo", null, true),
new("keep per-tab drafts", null, true),
new("newline key", "Shift+Enter", null),
new(string.Empty, null, null),
new("├ SPELLCHECK", null, null),
new("check spelling", null, true),
new("dictionary", "en_US", null),
};

return Render("Input & spellcheck", "F8", rows);
}
/// <summary>A dim group heading, marked by the branch glyph the screens prefix them with.</summary>
private static bool IsSection(OptionRow row) => row.Label.StartsWith("├ ", StringComparison.Ordinal);

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 | 🔵 Trivial | ⚡ Quick win

IsSection relies on a display-glyph prefix as its type discriminator.

A label that happens to start with "├ " would be silently treated as a section header. Since OptionRow already carries Value/Toggle to discriminate other kinds, consider an explicit discriminator (e.g. a Kind field or bool IsSection) instead of matching on the rendered glyph.

🤖 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 `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs` around lines 103 - 108, Replace
the rendered-glyph check in IsSection with an explicit OptionRow discriminator,
such as its existing kind or section flag, and update section-row construction
to set that discriminator. Preserve normal labels beginning with “├ ” as
ordinary option rows.

Comment thread src/SharpMUTerm.Tui/TriggersScreenRenderer.cs Outdated
Comment thread src/SharpMUTerm.Tui/WorldsScreenRenderer.cs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@tests/SharpMUTerm.Tui.Tests/KeypadScreenRendererTests.cs`:
- Line 42: Replace the obsolete HasCount() assertion in the keypad screen
renderer test with the current Count() assertion, preserving the expected count
of 3.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f2502254-7ac6-4f1a-88d9-9c6a9e89fa6c

📥 Commits

Reviewing files that changed from the base of the PR and between 0198a1d and 1c86928.

📒 Files selected for processing (3)
  • src/SharpMUTerm.Tui/DemoScene.cs
  • src/SharpMUTerm.Tui/KeypadScreenRenderer.cs
  • tests/SharpMUTerm.Tui.Tests/KeypadScreenRendererTests.cs

Comment thread tests/SharpMUTerm.Tui.Tests/KeypadScreenRendererTests.cs Outdated
Backlog item 3. The F2-F9 screens were display-only projections whose
header hints advertised behaviour that did not exist. Now ↑↓ moves the
selection, ⇥ crosses panes, Space toggles the boolean rows, Esc cancels
and ⏎ saves -- on all eight screens.

Field editing is deliberately NOT built, and no header claims it is: the
hints now read "↑↓ select · ⇥ pane · Space toggle", with a test
asserting no screen advertises a key its ScreenModel doesn't offer. A
hint that lies is worse than a missing feature.

Interaction state lives in four pure types (ScreenSelection, ScreenModel,
ScreenEdits, SettingsSession) so the rules are testable without a
terminal; SettingsOverlay stays the only UI-aware piece. Core remains
UI-agnostic.

Cancel replays an undo log rather than restoring a cloned config --
cloning AppConfiguration would drop [JsonIgnore] fields such as a
character's in-memory password. A toggle snapshots the value, not the
boolean, because F9's checkbox is really a LogFormat: cancelling puts
Html back, not Plain.

Supporting changes worth review:
- Alias.CaseSensitive, Trigger.StopProcessing, TriggerActions.Gag and
  TimerDefinition.OneShot become settable. Alias.CaseSensitive drops the
  cached compiled Regex, or the matcher would silently keep the old
  casing.
- New AppConfiguration.Text/.Input. F7/F8 rendered hardcoded literals
  with nothing behind them, so "toggle and save" would have been a lie.
  Purely additive: defaults match the old constants, no schema bump.
- ActiveLogging() falls back to the first configured character instead
  of returning a throwaway LoggingSettings, which made F9's toggle a
  silent no-op while disconnected.

Also unifies the left-column width: the renderer padded cursor bars to
54 while the view laid the column out at 56, leaving a visible gap
before the rule. One constant now, so they cannot drift.

Builds SharpConsoleUI from source when the framework is cloned beside
this repo, so it can be read and stepped into instead of decompiled. CI
and anyone without the clone fall through to the NuGet package
unchanged; -p:UseSharpConsoleUIPackage=true forces the package. Both
paths verified.

574 tests pass, up from 515.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/SharpMUTerm.Tui/TimersScreenRenderer.cs (1)

170-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Checkbox-row markup ([x]/[ ] + label) is implemented three separate times. TimersScreenRenderer and TriggersScreenRenderer each define a byte-identical private Checkbox(label, value) helper, and AliasesScreenRenderer.BuildEditor inlines the same pattern (minus the Escape(label) call). This PR already centralizes Escape/PadVisible/SpreadLR/Accent into shared MarkupText/ScreenPalette helpers specifically to prevent this kind of drift — Checkbox was missed.

  • src/SharpMUTerm.Tui/TimersScreenRenderer.cs#L170-L191: extract this Checkbox into a shared helper (e.g. MarkupText.Checkbox or ScreenChrome) and call it here.
  • src/SharpMUTerm.Tui/TriggersScreenRenderer.cs#L263-L265: remove this duplicate and call the same shared helper.
  • src/SharpMUTerm.Tui/AliasesScreenRenderer.cs#L169-L188: replace the inlined caseRow ternary with a call to the shared helper (also fixes the missing Escape(label) here).
🤖 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 `@src/SharpMUTerm.Tui/TimersScreenRenderer.cs` around lines 170 - 191,
Centralize the checkbox-row markup in a shared helper, such as
MarkupText.Checkbox or ScreenChrome, preserving accent styling for checked
values, dim styling for unchecked values, and escaped labels. Update
TimersScreenRenderer.BuildEditor and TriggersScreenRenderer to call it and
remove their private Checkbox helpers; update AliasesScreenRenderer.BuildEditor
to replace its inline caseRow ternary with the shared helper. Apply these
changes in src/SharpMUTerm.Tui/TimersScreenRenderer.cs#L170-L191,
src/SharpMUTerm.Tui/TriggersScreenRenderer.cs#L263-L265, and
src/SharpMUTerm.Tui/AliasesScreenRenderer.cs#L169-L188.
♻️ Duplicate comments (2)
src/SharpMUTerm.Tui/OptionsScreenRenderer.cs (1)

142-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

IsSection still relies on the rendered glyph prefix as a type discriminator.

Same concern from the prior review: a label incidentally starting with "├ " would be silently treated as a section header. Consider an explicit OptionRow discriminator instead of matching on the glyph.

🤖 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 `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs` around lines 142 - 147, The
IsSection method incorrectly classifies rows by matching the rendered “├ ” label
prefix. Add or reuse an explicit OptionRow discriminator for section headings
and update IsSection to check that discriminator, preserving spacer detection
and avoiding label-based classification.
src/SharpMUTerm.Tui/WorldsScreenRenderer.cs (1)

339-346: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hex(TerminalColor) duplication still unresolved.

This mirrors a prior review comment: Hex is still duplicated with TriggersScreenRenderer's implementation. Consider extracting the shared helper (e.g. into MarkupText/ScreenPalette, already using static-imported here) instead of keeping a local copy in each renderer.

🤖 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 `@src/SharpMUTerm.Tui/WorldsScreenRenderer.cs` around lines 339 - 346, Remove
the local Hex(TerminalColor) implementation from the renderer and reuse a shared
Hex helper extracted into the existing shared utility such as MarkupText or
ScreenPalette. Update both SharpMUTerm renderers to call that single helper
while preserving the current RGB formatting and Accent fallback behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs`:
- Around line 204-215: Update OptionsScreenRenderer.LoggingScreen so the last
non-None LogFormat is stored separately and survives redraws; only update that
stored value when logging.Format is non-None. Use it when toggling auto-start
back on, while preserving the existing Plain fallback when no prior format
exists.

In `@src/SharpMUTerm.Tui/SharpMUTermApp.cs`:
- Around line 755-771: Move the blocking save operation in SaveConfiguration off
the UI thread by making the method asynchronous and awaiting an asynchronous
configuration-store save. Update the synchronous caller in SettingsOverlay.OnKey
to await or otherwise schedule SaveConfiguration without blocking the
render/input loop, while preserving session capture and existing
exception/status handling.

---

Outside diff comments:
In `@src/SharpMUTerm.Tui/TimersScreenRenderer.cs`:
- Around line 170-191: Centralize the checkbox-row markup in a shared helper,
such as MarkupText.Checkbox or ScreenChrome, preserving accent styling for
checked values, dim styling for unchecked values, and escaped labels. Update
TimersScreenRenderer.BuildEditor and TriggersScreenRenderer to call it and
remove their private Checkbox helpers; update AliasesScreenRenderer.BuildEditor
to replace its inline caseRow ternary with the shared helper. Apply these
changes in src/SharpMUTerm.Tui/TimersScreenRenderer.cs#L170-L191,
src/SharpMUTerm.Tui/TriggersScreenRenderer.cs#L263-L265, and
src/SharpMUTerm.Tui/AliasesScreenRenderer.cs#L169-L188.

---

Duplicate comments:
In `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs`:
- Around line 142-147: The IsSection method incorrectly classifies rows by
matching the rendered “├ ” label prefix. Add or reuse an explicit OptionRow
discriminator for section headings and update IsSection to check that
discriminator, preserving spacer detection and avoiding label-based
classification.

In `@src/SharpMUTerm.Tui/WorldsScreenRenderer.cs`:
- Around line 339-346: Remove the local Hex(TerminalColor) implementation from
the renderer and reuse a shared Hex helper extracted into the existing shared
utility such as MarkupText or ScreenPalette. Update both SharpMUTerm renderers
to call that single helper while preserving the current RGB formatting and
Accent fallback behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 05f147da-572b-4bbb-bd21-f96603407865

📥 Commits

Reviewing files that changed from the base of the PR and between 1c86928 and 0e5ac5c.

📒 Files selected for processing (36)
  • CLAUDE.md
  • docs/HANDOFF.md
  • src/SharpMUTerm.Core/Automation/Alias.cs
  • src/SharpMUTerm.Core/Automation/TimerDefinition.cs
  • src/SharpMUTerm.Core/Automation/Trigger.cs
  • src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
  • src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs
  • src/SharpMUTerm.Tui/AliasesScreenRenderer.cs
  • src/SharpMUTerm.Tui/AliasesScreenView.cs
  • src/SharpMUTerm.Tui/KeypadScreenRenderer.cs
  • src/SharpMUTerm.Tui/KeypadScreenView.cs
  • src/SharpMUTerm.Tui/OptionsScreenRenderer.cs
  • src/SharpMUTerm.Tui/OptionsScreenView.cs
  • src/SharpMUTerm.Tui/ScreenChrome.cs
  • src/SharpMUTerm.Tui/ScreenEdits.cs
  • src/SharpMUTerm.Tui/ScreenFocus.cs
  • src/SharpMUTerm.Tui/ScreenModel.cs
  • src/SharpMUTerm.Tui/ScreenPalette.cs
  • src/SharpMUTerm.Tui/ScreenSelection.cs
  • src/SharpMUTerm.Tui/SettingsOverlay.cs
  • src/SharpMUTerm.Tui/SettingsSession.cs
  • src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • src/SharpMUTerm.Tui/TimersScreenRenderer.cs
  • src/SharpMUTerm.Tui/TimersScreenView.cs
  • src/SharpMUTerm.Tui/TriggersScreenRenderer.cs
  • src/SharpMUTerm.Tui/TriggersScreenView.cs
  • src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
  • src/SharpMUTerm.Tui/WorldsScreenView.cs
  • tests/SharpMUTerm.Core.Tests/Configuration/ConfigurationTests.cs
  • tests/SharpMUTerm.Tui.Tests/KeypadScreenRendererTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenCursorTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenEditsTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenSelectionTests.cs
  • tests/SharpMUTerm.Tui.Tests/SettingsSessionTests.cs

Comment on lines +204 to +215
// "Auto-start" is really the log format: off means None, on means whatever format was last
// chosen (Plain when there isn't one). The binding's snapshot restores the *format*, not the
// boolean, so cancelling a toggle-off puts Html back rather than downgrading it to Plain.
var chosen = logging.Format == LogFormat.None ? LogFormat.Plain : logging.Format;
var autoStart = new ScreenToggle(
() => logging.Format != LogFormat.None,
() => logging.Format = logging.Format == LogFormat.None ? chosen : LogFormat.None,
() =>
{
var previous = logging.Format;
return () => logging.Format = previous;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect LoggingSettings to see if a preferred/last-format field already exists.
fd -HI 'LoggingSettings.cs' | xargs -r cat -n
rg -n 'LogFormat' src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs src -g '*.cs' -A2 -B2

Repository: SharpMUSH/SharpMUTerm

Length of output: 3982


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the toggle implementation and the surrounding renderer code.
fd -HI 'ScreenToggle.cs' 'src' | xargs -r cat -n
printf '\n--- OptionsScreenRenderer excerpt ---\n'
sed -n '190,230p' src/SharpMUTerm.Tui/OptionsScreenRenderer.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 2231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect ScreenToggle implementation.
fd -HI 'ScreenToggle.cs' src | head -n 20

Repository: SharpMUSH/SharpMUTerm

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ScreenToggle.cs ---'
fd -HI 'ScreenToggle.cs' src | xargs -r cat -n

echo
echo '--- OptionsScreenRenderer.cs excerpt ---'
sed -n '190,230p' src/SharpMUTerm.Tui/OptionsScreenRenderer.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 2258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- files named ScreenToggle.cs / OptionsScreen*.cs / ScreenModel.cs ---'
fd -a 'ScreenToggle.cs|OptionsScreen.*\.cs|ScreenModel\.cs' src

echo
echo '--- references to LoggingScreen / ScreenToggle / OptionsScreen ---'
rg -n 'LoggingScreen\(|ScreenToggle|class OptionsScreen|record OptionsScreen|struct OptionsScreen|new OptionsScreen\(' src/SharpMUTerm.Tui src/SharpMUTerm.Core -g '*.cs' -A3 -B3

Repository: SharpMUSH/SharpMUTerm

Length of output: 20811


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- candidate files ---'
fd -HI 'ScreenToggle.cs' src
fd -HI 'OptionsScreen.*\.cs' src
fd -HI 'ScreenModel.cs' src

echo
echo '--- logging screen call sites / model definitions ---'
rg -n 'LoggingScreen\(|ScreenToggle|class OptionsScreen|record OptionsScreen|new OptionsScreen\(' src -g '*.cs' -A3 -B3

Repository: SharpMUSH/SharpMUTerm

Length of output: 20425


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- candidate files ---'
fd -HI 'ScreenToggle.cs' src
fd -HI 'OptionsScreen*.cs' src
fd -HI 'ScreenModel.cs' src

echo
echo '--- logging screen call sites / model definitions ---'
rg -n 'LoggingScreen\(|ScreenToggle|class OptionsScreen|record OptionsScreen|new OptionsScreen\(' src -g '*.cs' -A3 -B3

Repository: SharpMUSH/SharpMUTerm

Length of output: 20339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ScreenBinding definition and OptionsScreen call path ---'
rg -n 'class ScreenBinding|record ScreenBinding|struct ScreenBinding|OptionsScreen\(' src/SharpMUTerm.Tui -g '*.cs' -A4 -B4

echo
echo '--- surrounding SharpMUTermApp logging screen code ---'
sed -n '880,930p' src/SharpMUTerm.Tui/SharpMUTermApp.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 10257


Preserve the last non-None log format across redraws In OptionsScreenRenderer.LoggingScreen(...), chosen is recomputed from logging.Format on every repaint, so an off→on toggle in the same session falls back to Plain instead of restoring the prior format. Store the last non-None format separately.

🤖 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 `@src/SharpMUTerm.Tui/OptionsScreenRenderer.cs` around lines 204 - 215, Update
OptionsScreenRenderer.LoggingScreen so the last non-None LogFormat is stored
separately and survives redraws; only update that stored value when
logging.Format is non-None. Use it when toggling auto-start back on, while
preserving the existing Plain fallback when no prior format exists.

Comment on lines +755 to 771
/// <summary>
/// Persists the configuration the settings screens edit — the ⏎ Save action. The workspace layout
/// is captured alongside it so a save never rolls back the resumed session; a failed write is
/// swallowed for the same reason startup's is (the config is a convenience, not the session).
/// </summary>
private void SaveConfiguration()
{
try
{
_config.LastSession = CaptureSession();
ConfigurationStore.Save(ConfigurationStore.DefaultPath, _config);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
SetStatus($"[red]could not save settings:[/] {Escape(ex.Message)}");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check ConfigurationStore.Save signature and other call sites.
fd -i ConfigurationStore.cs | xargs -I{} sed -n '1,100p' {}
rg -n 'ConfigurationStore\.Save' --type=cs -C3

Repository: SharpMUSH/SharpMUTerm

Length of output: 2975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation and callers of SaveConfiguration.
ast-grep outline src/SharpMUTerm.Tui/SharpMUTermApp.cs --view expanded >/tmp/outline.txt
sed -n '1,220p' /tmp/outline.txt

printf '\n--- SaveConfiguration block ---\n'
rg -n 'SaveConfiguration|ConfigurationStore\.Save|EnqueueOnUIThread|Save action|keyboard' src/SharpMUTerm.Tui/SharpMUTermApp.cs -C6

printf '\n--- surrounding lines near SaveConfiguration ---\n'
sed -n '720,790p' src/SharpMUTerm.Tui/SharpMUTermApp.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 14516


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect SettingsOverlay and any async save-related APIs.
fd -a 'SettingsOverlay.cs' src
printf '\n--- SettingsOverlay outline ---\n'
ast-grep outline src/SharpMUTerm.Tui/SettingsOverlay.cs --view expanded

printf '\n--- save-related symbols and async APIs ---\n'
rg -n 'SaveConfiguration|EnqueueOnUIThread|ConfigurationStore\.Save|Task<|async Task|async void|Save\(' src/SharpMUTerm.Tui src/SharpMUTerm.Core -C3

Repository: SharpMUSH/SharpMUTerm

Length of output: 14741


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect SettingsOverlay's key handling and save invocation.
sed -n '1,220p' src/SharpMUTerm.Tui/SettingsOverlay.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 5370


Move the settings save off the UI thread. SaveConfiguration() is invoked synchronously from SettingsOverlay.OnKey, and ConfigurationStore.Save(...) is just File.WriteAllText(...), so a slow disk write can stall the render/input loop. src/SharpMUTerm.Tui/SharpMUTermApp.cs:760-765

🤖 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 `@src/SharpMUTerm.Tui/SharpMUTermApp.cs` around lines 755 - 771, Move the
blocking save operation in SaveConfiguration off the UI thread by making the
method asynchronous and awaiting an asynchronous configuration-store save.
Update the synchronous caller in SettingsOverlay.OnKey to await or otherwise
schedule SaveConfiguration without blocking the render/input loop, while
preserving session capture and existing exception/status handling.

Source: Coding guidelines

Backlog item 5. DropZones.Resolve existed in Core and was tested, but
nothing in the TUI ever called it. Dragging a pane's tab onto another
pane now splits or re-tabs it: drop within 25% of the left or right edge
to split side by side, top or bottom to stack, or in the middle to move
the tab into that pane.

The wiring sits at the driver, not on a control, and that is forced by
the framework: WindowEventDispatcher captures the pressed control and
routes every later drag frame back to it, so a control-level handler
would only ever see the source pane and never the one being dragged
onto.

Most of the logic is pure and tested -- the gesture state machine, flag
decoding, hit-testing, preview geometry -- leaving the thinnest possible
adapter over framework events. PaneDragEndToEndTests additionally drives
real press/drag/release frames through the app on a headless driver,
parameterised over all four edges, asserting both split direction and
child ordering.

Move mode's status bar has always advertised "←↑↓→ edge" with no arrow
handling behind it, so only the tab-drop half was reachable and
SplitWithWindow was never called from the TUI. Both paths now commit
through one PaneDrop, so keyboard and mouse cannot drift.

649 tests pass, up from 574.

Unverified until someone uses a real mouse: whether the terminal reports
drags at all, and whether the preview keeps up at pointer speed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/SharpMUTerm.Tui/SharpMUTermApp.cs`:
- Around line 1873-1927: Marshal the entire OnDriverMouseEvent callback onto the
UI thread, including overlay checks, PaneSnapshot, _paneDrag.Handle, and
ApplyDragResult. Ensure the driver callback only schedules this work through
OnUiThread, so all _paneDrag mutations and PaneSnapshot layout reads occur on
the same UI thread as BuildDragPane and the Escape handler.

In `@tests/SharpMUTerm.Core.Tests/Workspace/PaneDropTests.cs`:
- Line 25: Update the tab assertions in the affected tests, including the
assertions near lines 25 and 66, to pass CollectionOrdering.Matching to
IsEquivalentTo so the expected sequence order is enforced while retaining the
existing tab values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 41fb1442-3620-4e8e-b74f-d3f47b7c7878

📥 Commits

Reviewing files that changed from the base of the PR and between 0e5ac5c and 823c78d.

📒 Files selected for processing (13)
  • CLAUDE.md
  • docs/HANDOFF.md
  • src/SharpMUTerm.Core/Workspace/PaneDrop.cs
  • src/SharpMUTerm.Tui/PaneDragSurface.cs
  • src/SharpMUTerm.Tui/PaneDragTracker.cs
  • src/SharpMUTerm.Tui/PaneDropRenderer.cs
  • src/SharpMUTerm.Tui/Program.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/PaneDropTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneDragEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneDragSurfaceTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneDragTrackerTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneDropRendererTests.cs

Comment on lines +1873 to +1927
private void OnDriverMouseEvent(object sender, List<MouseFlags> flags, System.Drawing.Point point)
{
// Overlays own the whole screen while they're up; a drag underneath them would target panes
// the user can't even see.
if (_palette.IsOpen || _settings.IsOpen || _moveMode)
{
return;
}

var result = _paneDrag.Handle(flags, point.X, point.Y, PaneSnapshot);
if (result.Action == PaneDragAction.None)
{
return;
}

OnUiThread(() => ApplyDragResult(result));
}

/// <summary>
/// Reads the pane area's live geometry back out of the framework's arranged layout, in desktop
/// cells. A control's <see cref="SharpConsoleUI.Layout.LayoutNode.AbsoluteBounds"/> is in
/// window-content space, so the window's own origin and inset are added back on.
/// Internal so a headless test can check the mapping against the framework's own hit testing —
/// it is the one part of the drag that no pure unit test can pin down.
/// </summary>
internal PaneDragSurface PaneSnapshot()
{
var origin = ContentOrigin();
var rects = new Dictionary<string, PaneRect>(StringComparer.Ordinal);
var windows = new Dictionary<string, string>(StringComparer.Ordinal);

KeyValuePair<string, TabControl>[] realised;
lock (_paneTabsLock)
{
realised = _paneTabs.ToArray();
}

foreach (var (paneId, tabs) in realised)
{
if (_window.GetLayoutNode(tabs) is not { } node)
{
continue;
}

var bounds = node.AbsoluteBounds;
rects[paneId] = new PaneRect(origin.X + bounds.X, origin.Y + bounds.Y, bounds.Width, bounds.Height);

if (_workspace.Layout.FindPane(paneId)?.ActiveTab is { } windowId)
{
windows[paneId] = windowId;
}
}

return new PaneDragSurface(rects, windows);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is SharpConsoleUI's Window/LayoutNode API thread-safe for concurrent access from a background input thread while the UI thread renders?

💡 Result:

SharpConsoleUI's Window and LayoutNode API are not designed for direct, thread-safe concurrent access from a background thread while the UI thread is rendering [1][2]. The framework enforces a strict threading model to ensure stability [1][2]: 1. Threading Model: All UI rendering operations (Measure, Arrange, Paint) occur exclusively on the main UI thread, protected by a render lock (_renderLock) [3][1][4][2]. Background threads are not permitted to modify window state or layout tree nodes directly [1][2]. 2. Communication: Background threads, such as an input loop, must communicate with the UI thread by enqueuing actions or data (e.g., via an input queue) rather than accessing UI objects directly [1][2][5]. The library provides synchronization mechanisms specifically for this, such as _inputQueue or marshalling via EnqueueOnUIThread [1][2][6][5]. 3. Lock Hierarchy: The library maintains a strict internal lock hierarchy (_renderLock -> window._lock -> _consoleLock) to prevent deadlocks [1][2]. Accessing or modifying internal state from outside these established patterns can lead to race conditions or thread safety violations [1][2][5]. In summary, you must marshal any changes to LayoutNode or Window state to the UI thread [1][2][6]. Attempting to manipulate these objects directly from a background thread while the UI thread is rendering is unsafe and violates the library's concurrency architecture [1][2].

Citations:


🏁 Script executed:

# Inspect the relevant code paths and surrounding threading assumptions.
git ls-files | rg 'SharpMUTermApp\.cs|PaneDrag|OnDriverMouseEvent|OnWindowKey|BuildDragPane|RebuildPaneArea|OnUiThread|GetLayoutNode|AbsoluteBounds'

Repository: SharpMUSH/SharpMUTerm

Length of output: 432


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in src/SharpMUTerm.Tui/SharpMUTermApp.cs src/SharpMUTerm.Tui/PaneDragTracker.cs src/SharpMUTerm.Tui/PaneDragSurface.cs tests/SharpMUTerm.Tui.Tests/PaneDragTrackerTests.cs tests/SharpMUTerm.Tui.Tests/PaneDragSurfaceTests.cs tests/SharpMUTerm.Tui.Tests/PaneDragEndToEndTests.cs; do
  echo "===== $f ====="
  wc -l "$f"
done

echo "===== SharpMUTermApp relevant symbols ====="
ast-grep outline src/SharpMUTerm.Tui/SharpMUTermApp.cs --view expanded | sed -n '1,260p'

echo "===== PaneDragTracker outline ====="
ast-grep outline src/SharpMUTerm.Tui/PaneDragTracker.cs --view expanded | sed -n '1,260p'

echo "===== OnDriverMouseEvent / OnWindowKey / BuildDragPane / RebuildPaneArea / OnUiThread references ====="
rg -n -A 6 -B 6 'OnDriverMouseEvent|OnWindowKey|BuildDragPane|RebuildPaneArea|OnUiThread|GetLayoutNode|AbsoluteBounds|PaneSnapshot|_paneDrag' src/SharpMUTerm.Tui/SharpMUTermApp.cs src/SharpMUTerm.Tui/PaneDragTracker.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 50377


Marshal the whole mouse-drag callback onto the UI thread. _paneDrag is mutated in OnDriverMouseEvent while BuildDragPane and the Escape handler touch the same tracker on the UI thread, and PaneSnapshot() also reads Window.GetLayoutNode/LayoutNode.AbsoluteBounds directly. Moving only ApplyDragResult still leaves those races in place.

🤖 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 `@src/SharpMUTerm.Tui/SharpMUTermApp.cs` around lines 1873 - 1927, Marshal the
entire OnDriverMouseEvent callback onto the UI thread, including overlay checks,
PaneSnapshot, _paneDrag.Handle, and ApplyDragResult. Ensure the driver callback
only schedules this work through OnUiThread, so all _paneDrag mutations and
PaneSnapshot layout reads occur on the same UI thread as BuildDragPane and the
Escape handler.


var pane = layout.FindWindow("b")!;
await Assert.That(pane.Id).IsEqualTo(target);
await Assert.That(pane.Tabs).IsEquivalentTo(new[] { "c", "b" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C2 'IsEquivalentTo\(' tests/SharpMUTerm.Core.Tests/Workspace/PaneDropTests.cs
rg -n --glob '*.cs' 'CollectionOrdering\.Matching' .

Repository: SharpMUSH/SharpMUTerm

Length of output: 1190


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

sed -n '1,140p' tests/SharpMUTerm.Core.Tests/Workspace/PaneDropTests.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 4810


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

web_search
TUnit IsEquivalentTo collection ordering default behavior

Repository: SharpMUSH/SharpMUTerm

Length of output: 207


🌐 Web query:

TUnit IsEquivalentTo collection ordering default behavior

💡 Result:

In the TUnit framework, the default behavior of the IsEquivalentTo assertion is to ignore the order of elements [1][2]. It focuses solely on whether the collections contain the same items with the same counts [1]. To require that elements must appear in the same order, you must explicitly pass the CollectionOrdering.Matching parameter to the method [1][2]. Example of default behavior (order-independent): await Assert.That(new[] { 1, 2, 3 }).IsEquivalentTo(new[] { 3, 2, 1 }); // Passes Example of order-sensitive behavior: await Assert.That(new[] { 1, 2, 3 }).IsEquivalentTo(new[] { 1, 2, 3 }, CollectionOrdering.Matching); // Passes await Assert.That(new[] { 1, 2, 3 }).IsEquivalentTo(new[] { 3, 2, 1 }, CollectionOrdering.Matching); // Fails There is no global configuration option to change this default; the explicit parameter approach is maintained to ensure clarity and consistency [2].

Citations:


Assert tab order explicitly. Lines 25 and 66 use IsEquivalentTo(...), which ignores element order, so a reordered Tabs sequence would still pass. Pass CollectionOrdering.Matching here if the test is meant to protect tab order.

🤖 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 `@tests/SharpMUTerm.Core.Tests/Workspace/PaneDropTests.cs` at line 25, Update
the tab assertions in the affected tests, including the assertions near lines 25
and 66, to pass CollectionOrdering.Matching to IsEquivalentTo so the expected
sequence order is enforced while retaining the existing tab values.

HarryCordewener and others added 2 commits July 28, 2026 17:40
Backlog item 2, the last unwired piece. SharpMUTerm.Graphics existed and
was tested but nothing in the TUI ever called it, so "inline graphics,
in scope from day one" had never once put a picture on screen.

Wired at the <img> seam in the web view, which is the only place in the
codebase where an image URL survives: MXP and Pueblo both parse <IMG>
and discard it, and HtmlStyledRenderer already produced a placeholder.
HtmlStyledRenderer now indexes each placeholder line, WebPage carries
the images, and WebViewComposer splits the page into text and image
blocks for the framework's ImageControl.

Rendering goes through the framework rather than our own encoder, and
that is forced rather than preferred: our Kitty and Sixel encoders emit
escape-sequence strings, but a compositor owns every cell, Cell has no
raw-escape field, and AppendCombiner strips escapes as an anti-injection
defence. There is nowhere to put an escape blob. The framework's
renderer works because it writes U+10EEEE placeholder cells -- images
become real cells, which is what docs/PLAN.md committed to.

Negative finding worth recording: the framework has no Sixel back-end at
v2.5.14, and IImageRenderer is internal with ResolveRenderer private, so
one cannot be injected. A Sixel-only terminal therefore degrades to
half-blocks. That is stated in the policy's own Describe() output rather
than failing silently. Lifting it needs an upstream PR.

Degradation is Kitty -> half-block -> text placeholder, and when nothing
is supported no image is fetched at all. That chain is the part
verifiable without a graphics terminal, so it is tested exhaustively:
every protocol x surface combination, plus an invariant that no
combination ever upgrades past what was detected.

SharpMUTerm.Graphics keeps its own encoders, still reachable for a
raw-terminal host; nothing deleted in this pass.

764 tests pass, up from 649.

Not verified: that a Kitty image actually appears. The transmission,
encode and placement are framework paths only a real GPU terminal
exercises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backlog had become a write-up of finished work -- four of six items
were descriptions of things already built, so a session looking for
remaining work had to read ~180 lines to find the few paragraphs that
still matter. A handoff should say what is true now, not narrate how it
got here.

What is left is now five items: field editing on the config screens,
the real-terminal verification still owed, Sixel inside the compositor
(blocked upstream), MXP/Pueblo <IMG> routing, and the CodeRabbit
nitpicks a future agent must not "fix".

The durable framework knowledge those items carried moves into Critical
Gotchas, where it applies regardless of what is built: why
SupportsKittyGraphics reads false in a constructor, why our escape-string
encoders cannot go into a compositor, why drag wiring lives at the driver
rather than on a control, and the settings-screen architecture.

Corrects the IImageRenderer path, which is in Imaging/, not
Controls/ImageControl/. Framework citations re-checked against the
v2.5.14 clone: ImageControl.cs:375 is ResolveRenderer, Cell.cs:122 is
AppendCombiner, and Sixel appears only in a forward-looking comment and
a docs comparison table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HarryCordewener HarryCordewener changed the title Settings screens: apply the F5 panel treatment to F2-F9 Settings screens, keyboard interaction, mouse drag-to-split, and inline images Jul 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/SharpMUTerm.Graphics/InlineImagePolicy.cs`:
- Around line 127-130: Replace the numeric enum comparison in Describe with an
explicit GraphicsProtocol-to-InlineImagePresentation mapping, using a helper
such as PresentationFor and handling unsupported protocols with TextPlaceholder.
Compare chosen against the mapped detected value, keeping the existing match
description behavior.

In `@src/SharpMUTerm.Tui/SharpMUTermApp.cs`:
- Around line 1368-1384: Update the web-page navigation flow around
RebuildPaneArea, BuildTabContent, and BuildWebContent so the pane is rebuilt
whenever the outgoing page has composed image content, not only when isNew is
true. Ensure the old ScrollablePanel and its extra MarkupControl/ImageControl
children are replaced before activating the new page, while preserving the
existing markup update and image-loading behavior.
- Around line 1369-1372: In the cleanup flow around _webImageCts, cancel the
source and clear the reference but do not dispose it there. Move disposal into
the finally block of LoadWebImagesAsync, after the background load has exited,
so its token remains valid through _imageLoader.LoadAsync and
HttpClient.GetAsync.

In `@src/SharpMUTerm.Tui/WebImageLoader.cs`:
- Around line 152-158: Replace the full-body ReadAsByteArrayAsync usage in the
image-loading method with the existing WebPageFetcher.ReadCappedAsync helper,
passing MaxImageBytes and the cancellation token so the response is capped while
streaming. Retain the Content-Length fast rejection and return the capped result
without materializing oversized responses.

In `@src/SharpMUTerm.Web/HtmlStyledRenderer.cs`:
- Around line 184-202: Update EmitImage so the placeholder label is written
through the non-wrapping/preformatted path, while preserving its existing
styling, link, and image index behavior. Ensure long alt text remains on the
image’s recorded line and add a WebImageIndexTests case using a narrow width
with sufficiently long alt text to verify it does not wrap.

In `@tests/SharpMUTerm.Tui.Tests/WebImageLoaderTests.cs`:
- Around line 161-175: Add a test alongside NonHttpSources_AreNeverFetched that
injects a stub HttpClient handler into WebImageLoader, returns an oversized
image/png response without a Content-Length header, and verifies LoadAsync
rejects it at the configured byte cap. Keep the test entirely in-memory and
cover the HTTP fetch path rather than the existing data: handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f283e9d0-2fdf-4bb4-8f6f-72df278bc51c

📥 Commits

Reviewing files that changed from the base of the PR and between 823c78d and 029772c.

📒 Files selected for processing (18)
  • CLAUDE.md
  • docs/HANDOFF.md
  • src/SharpMUTerm.Graphics/InlineImagePolicy.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • src/SharpMUTerm.Tui/WebImageLayout.cs
  • src/SharpMUTerm.Tui/WebImageLoader.cs
  • src/SharpMUTerm.Tui/WebViewComposer.cs
  • src/SharpMUTerm.Web/HtmlStyledRenderer.cs
  • src/SharpMUTerm.Web/LineWriter.cs
  • src/SharpMUTerm.Web/WebImage.cs
  • src/SharpMUTerm.Web/WebPage.cs
  • src/SharpMUTerm.Web/WebPageFetcher.cs
  • tests/SharpMUTerm.Graphics.Tests/InlineImagePolicyTests.cs
  • tests/SharpMUTerm.Tui.Tests/WebImageLayoutTests.cs
  • tests/SharpMUTerm.Tui.Tests/WebImageLoaderTests.cs
  • tests/SharpMUTerm.Tui.Tests/WebInlineImageEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/WebViewComposerTests.cs
  • tests/SharpMUTerm.Web.Tests/WebImageIndexTests.cs

Comment on lines +127 to +130
if ((int)chosen == (int)detected)
{
return $"inline images render via {chosen}";
}

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 | 🔵 Trivial | ⚡ Quick win

Coupling InlineImagePresentation to GraphicsProtocol by numeric value is fragile. (int)chosen == (int)detected only works while the two enums stay numerically aligned; adding a rung to either (e.g. an iTerm protocol) silently makes Describe claim a match that isn't one. An explicit mapping keeps the intent local.

♻️ Suggested mapping instead of a numeric cast
-        if ((int)chosen == (int)detected)
+        if (chosen == PresentationFor(detected))
         {
             return $"inline images render via {chosen}";
         }
private static InlineImagePresentation PresentationFor(GraphicsProtocol protocol) => protocol switch
{
    GraphicsProtocol.Kitty => InlineImagePresentation.Kitty,
    GraphicsProtocol.Sixel => InlineImagePresentation.Sixel,
    GraphicsProtocol.HalfBlock => InlineImagePresentation.HalfBlock,
    _ => InlineImagePresentation.TextPlaceholder,
};
📝 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
if ((int)chosen == (int)detected)
{
return $"inline images render via {chosen}";
}
if (chosen == PresentationFor(detected))
{
return $"inline images render via {chosen}";
}
private static InlineImagePresentation PresentationFor(GraphicsProtocol protocol) => protocol switch
{
GraphicsProtocol.Kitty => InlineImagePresentation.Kitty,
GraphicsProtocol.Sixel => InlineImagePresentation.Sixel,
GraphicsProtocol.HalfBlock => InlineImagePresentation.HalfBlock,
_ => InlineImagePresentation.TextPlaceholder,
};
🤖 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 `@src/SharpMUTerm.Graphics/InlineImagePolicy.cs` around lines 127 - 130,
Replace the numeric enum comparison in Describe with an explicit
GraphicsProtocol-to-InlineImagePresentation mapping, using a helper such as
PresentationFor and handling unsupported protocols with TextPlaceholder. Compare
chosen against the mapped detected value, keeping the existing match description
behavior.

Comment thread src/SharpMUTerm.Tui/SharpMUTermApp.cs
Comment on lines +1369 to +1372
_webImageCts?.Cancel();
_webImageCts?.Dispose();
_webImageCts = null;
_webImages.Clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does disposing a CancellationTokenSource while HttpClient.SendAsync is using its token throw ObjectDisposedException?

💡 Result:

Disposing a CancellationTokenSource (CTS) while an HttpClient request is using its token can indeed lead to an ObjectDisposedException, although the behavior depends on the specific timing and the internal state of the operation [1][2]. When you pass a CancellationToken to HttpClient.SendAsync, the client often creates a linked token source internally if a timeout is configured or if the provided token can be canceled [3][4]. If you dispose of the CTS that you provided to the request while that request is still active, any subsequent attempt by HttpClient or its underlying handlers to access the token—or to perform operations that check the state of the CTS—may throw an ObjectDisposedException [5][1]. This occurs because: 1. Accessing the Token property of a disposed CTS throws an ObjectDisposedException [5][1]. 2. Internal mechanisms within the HttpClient stack or the underlying network handlers may perform registration or state checks on the provided token or the linked token source after the request has been initiated [5][4]. If these operations occur after the object is disposed, the exception is triggered [1][2]. 3. Disposing the CTS does not inherently cancel the operation; it simply marks the source as disposed [6]. If you intend to cancel an ongoing request, you should call Cancel on the CancellationTokenSource instead of disposing it [1][6]. It is a best practice to keep the CancellationTokenSource alive until the HttpClient operation has completed (i.e., until the returned Task has finished) to avoid these race conditions [2][6]. If you are using a linked token source, ensure it remains in scope for the duration of the asynchronous operation [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and inspect the relevant symbols/regions.
ast-grep outline src/SharpMUTerm.Tui/SharpMUTermApp.cs --view expanded || true

echo
echo "=== nearby lines around 1369-1372 ==="
sed -n '1320,1415p' src/SharpMUTerm.Tui/SharpMUTermApp.cs

echo
echo "=== search for web image CTS lifecycle ==="
rg -n "_webImageCts|StartWebImageLoad|LoadWebImagesAsync|WebImageLoader.FetchAsync" src/SharpMUTerm.Tui/SharpMUTermApp.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 14207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the related loader implementation if it lives in the repo.
fd -a "WebImageLoader*.cs" src || true
fd -a "SharpMUTermApp.cs" src || true
rg -n "class WebImageLoader|FetchAsync|LoadWebImagesAsync|StartWebImageLoad" src || true

Repository: SharpMUSH/SharpMUTerm

Length of output: 1290


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If there is a separate file for the loader, inspect relevant sections.
for f in $(fd -a "WebImageLoader*.cs" src); do
  echo "=== $f ==="
  wc -l "$f"
  ast-grep outline "$f" --view expanded || true
done

Repository: SharpMUSH/SharpMUTerm

Length of output: 982


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== LoadWebImagesAsync region ==="
sed -n '1411,1495p' src/SharpMUTerm.Tui/SharpMUTermApp.cs

echo
echo "=== DisposeAsync region ==="
sed -n '2488,2525p' src/SharpMUTerm.Tui/SharpMUTermApp.cs

echo
echo "=== WebImageLoader.FetchAsync / LoadAsync region ==="
sed -n '40,150p' src/SharpMUTerm.Tui/WebImageLoader.cs

Repository: SharpMUSH/SharpMUTerm

Length of output: 8257


🌐 Web query:

.NET CancellationTokenSource dispose while HttpClient request with its token is still running ObjectDisposedException token registration

💡 Result:

In.NET, disposing a CancellationTokenSource (CTS) while an HttpClient request is still actively using its token is a race condition that can lead to an ObjectDisposedException [1]. This occurs because the HttpClient internal implementation may attempt to access the CTS—such as when triggering cancellation or cleaning up resources—after it has been explicitly disposed by your code [2][3][1]. The documentation explicitly states that Dispose must only be called when all other operations on the CTS have completed [4]. Because the lifetime of an asynchronous HTTP request is often difficult to track precisely, disposing the CTS while a request is in flight is generally considered unsafe [5][4]. Key points regarding this issue include: 1. Race Condition: When you call Dispose on a CTS, it enters an unusable state [6]. If the HttpClient (or another component) later attempts to interact with the CTS or its token, it will throw an ObjectDisposedException [3][7]. This is common in asynchronous flows where a finally block disposes the CTS while the underlying network operation is still technically finishing [3][1]. 2. Best Practices for Cleanup: - For standard requests, you generally do not need to dispose of a CTS immediately after the task completes if you are in a high-throughput environment, as the overhead is minimal unless the CTS was created with a linked token or a long-running timer [5]. - If you must dispose of the CTS, ensure the associated Task has fully completed (e.g., by awaiting the HttpClient request) before calling Dispose [4]. - If you are dealing with "fire-and-forget" or complex concurrent scenarios where you cannot guarantee the task completion, some developers choose to leave the CTS undisposed to avoid the race condition, allowing the garbage collector to reclaim the resources naturally [5][4]. 3. HttpClient Implementation: HttpClient is designed to consume the CancellationToken provided to it [8]. It does not "own" the CTS lifetime, meaning it is the caller's responsibility to ensure the token remains valid for the entire duration of the request [5]. If you are experiencing this exception, ensure that your disposal logic is strictly ordered after the await of the HttpClient call [3][4]. If your architecture makes this impossible, you may need to reconsider the CTS ownership or accept that not disposing of it is a safe trade-off to avoid the exception [5][4].

Citations:


Keep _webImageCts alive until LoadWebImagesAsync exits. Cancel here, but dispose it in the background task’s finally; this fire-and-forget load can still pass the token into _imageLoader.LoadAsync/HttpClient.GetAsync, and disposing the source mid-flight can throw ObjectDisposedException.

🤖 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 `@src/SharpMUTerm.Tui/SharpMUTermApp.cs` around lines 1369 - 1372, In the
cleanup flow around _webImageCts, cancel the source and clear the reference but
do not dispose it there. Move disposal into the finally block of
LoadWebImagesAsync, after the background load has exited, so its token remains
valid through _imageLoader.LoadAsync and HttpClient.GetAsync.

Comment on lines +152 to +158
if (response.Content.Headers.ContentLength > MaxImageBytes)
{
return null;
}

var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return bytes.Length > MaxImageBytes ? null : bytes;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded buffering when Content-Length is absent or wrong. The header check is skipped for chunked/null-length responses, and ReadAsByteArrayAsync then materialises the whole body before line 158 can reject it — so the 8 MB cap only applies after the allocation. WebPageFetcher.ReadCappedAsync already streams with a hard cap; do the same (or set MaxResponseContentBufferSize on the owned client).

🛡️ Cap while reading instead of after
-            var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
-            return bytes.Length > MaxImageBytes ? null : bytes;
+            await using var stream = await response.Content
+                .ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            using var buffered = new MemoryStream();
+            var chunk = new byte[81920];
+            int read;
+            while ((read = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
+            {
+                if (buffered.Length + read > MaxImageBytes)
+                {
+                    return null; // oversized: the placeholder stays
+                }
+
+                buffered.Write(chunk, 0, read);
+            }
+
+            return buffered.ToArray();
📝 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
if (response.Content.Headers.ContentLength > MaxImageBytes)
{
return null;
}
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return bytes.Length > MaxImageBytes ? null : bytes;
if (response.Content.Headers.ContentLength > MaxImageBytes)
{
return null;
}
await using var stream = await response.Content
.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var buffered = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
{
if (buffered.Length + read > MaxImageBytes)
{
return null; // oversized: the placeholder stays
}
buffered.Write(chunk, 0, read);
}
return buffered.ToArray();
🤖 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 `@src/SharpMUTerm.Tui/WebImageLoader.cs` around lines 152 - 158, Replace the
full-body ReadAsByteArrayAsync usage in the image-loading method with the
existing WebPageFetcher.ReadCappedAsync helper, passing MaxImageBytes and the
cancellation token so the response is capped while streaming. Retain the
Content-Length fast rejection and return the capped result without materializing
oversized responses.

Comment on lines +184 to +202
private void EmitImage(IElement element, LineWriter writer, TextStyle style, bool preformatted)
{
var alt = element.GetAttribute("alt");
var label = string.IsNullOrWhiteSpace(alt) ? "[image]" : $"[image: {alt}]";
var src = element.GetAttribute("src");
var hasSource = !string.IsNullOrWhiteSpace(src);
var resolved = hasSource ? Resolve(src!) : null;

writer.EndLine();
var lineIndex = writer.LineIndex;
var imgLink = resolved is null ? null : SpanInteraction.Link(resolved);
writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted);
writer.EndLine();

if (resolved is not null)
{
_images.Add(new WebImage(lineIndex, resolved, alt?.Trim() is { Length: > 0 } a ? a : null, label));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Long alt text can wrap the placeholder across two lines, breaking the LineIndex contract.

EmitImage captures lineIndex before calling writer.AddText(label, ..., preformatted) with preformatted: false for ordinary body content. LineWriter.AddText word-wraps when content exceeds _width (clamped down to as low as 20), so a placeholder like "[image: <long alt text>]" can split into two physical lines. The recorded WebImage.LineIndex then only points at the first fragment; WebViewComposer.Compose will start its trailing text block right after that line, leaking the placeholder's second half into the surrounding text once the image is drawn — producing a visibly broken page for any image with alt text that doesn't fit on one row.

Force the placeholder write path to never wrap, mirroring the width-agnostic behavior AddPreformatted already gives:

🐛 Proposed fix
         writer.EndLine();
         var lineIndex = writer.LineIndex;
         var imgLink = resolved is null ? null : SpanInteraction.Link(resolved);
-        writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted);
+        // Never let the placeholder wrap: it must land on exactly one line so the recorded
+        // lineIndex stays valid for image-capable views to swap it for a picture.
+        writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted: true);
         writer.EndLine();

Also worth adding a WebImageIndexTests case with alt text long enough to wrap at a narrow width, since none of the current tests exercise this path.

📝 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
private void EmitImage(IElement element, LineWriter writer, TextStyle style, bool preformatted)
{
var alt = element.GetAttribute("alt");
var label = string.IsNullOrWhiteSpace(alt) ? "[image]" : $"[image: {alt}]";
var src = element.GetAttribute("src");
var hasSource = !string.IsNullOrWhiteSpace(src);
var resolved = hasSource ? Resolve(src!) : null;
writer.EndLine();
var lineIndex = writer.LineIndex;
var imgLink = resolved is null ? null : SpanInteraction.Link(resolved);
writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted);
writer.EndLine();
if (resolved is not null)
{
_images.Add(new WebImage(lineIndex, resolved, alt?.Trim() is { Length: > 0 } a ? a : null, label));
}
}
private void EmitImage(IElement element, LineWriter writer, TextStyle style, bool preformatted)
{
var alt = element.GetAttribute("alt");
var label = string.IsNullOrWhiteSpace(alt) ? "[image]" : $"[image: {alt}]";
var src = element.GetAttribute("src");
var hasSource = !string.IsNullOrWhiteSpace(src);
var resolved = hasSource ? Resolve(src!) : null;
writer.EndLine();
var lineIndex = writer.LineIndex;
var imgLink = resolved is null ? null : SpanInteraction.Link(resolved);
// Never let the placeholder wrap: it must land on exactly one line so the recorded
// lineIndex stays valid for image-capable views to swap it for a picture.
writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted: true);
writer.EndLine();
if (resolved is not null)
{
_images.Add(new WebImage(lineIndex, resolved, alt?.Trim() is { Length: > 0 } a ? a : null, label));
}
}
🤖 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 `@src/SharpMUTerm.Web/HtmlStyledRenderer.cs` around lines 184 - 202, Update
EmitImage so the placeholder label is written through the
non-wrapping/preformatted path, while preserving its existing styling, link, and
image index behavior. Ensure long alt text remains on the image’s recorded line
and add a WebImageIndexTests case using a narrow width with sufficiently long
alt text to verify it does not wrap.

Comment thread tests/SharpMUTerm.Tui.Tests/WebImageLoaderTests.cs
HarryCordewener and others added 14 commits July 28, 2026 18:27
Backlog item 1's core. The screens could navigate and toggle but nothing
let you type -- no new host, port, interval or pattern.

Key scheme: ⏎ activates the focused row when it is activatable, and
saves when it is not, so ⏎ still means Save everywhere it did before.
⌃S always saves. Esc cancels the screen, except while a field edit is
open, where it abandons that edit and leaves the screen up.

A ScreenRow is now a stop, a checkbox, an ordered record of fields, or
both at once -- a keypad binding is Space-enables plus ⏎-edits-the-
command. Fields hang off rows that already exist rather than adding
rows of their own, which is what let every existing pane-count and
cursor-index assertion keep passing untouched.

Validation happens at commit, not per keystroke: any character types
into the buffer, and ⏎/⇥/⌃S validate. A rejected value keeps the edit
open and marks the field inline rather than being silently dropped.
Rejecting keystrokes instead would mean never being able to clear a port
to retype it. ScreenEdits.Apply is the single path from buffer to config
and refuses before snapshotting, so an invalid value reaches neither
config nor the undo log.

Trigger.Pattern and Alias.Pattern become settable and both drop the
cached compiled Regex -- the same trap Alias.CaseSensitive had, now with
a Core test each asserting the engine stops matching the old pattern.

The hint-honesty test is inverted rather than deleted: a screen must
advertise ⏎ edit if and only if its model actually offers an editable
row, asserted across all six screens in both a populated and a bare
configuration so it cannot pass vacuously.

Also fixes a flaky test that predates this work. PaneDragEndToEndTests
ran in parallel while RenderSnapshot redirects Console.Out and the
harness redirects Console.In -- both process-global, so two overlapping
renders swapped each other's streams and one got a truncated frame,
yielding pane rects that didn't match the screen. It failed roughly one
run in ten; the class is now serialised, and 15 consecutive runs are
clean.

811 tests pass, up from 764.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes backlog item 1. The add/remove buttons were painted but inert,
and F2's route list and highlight row were read-only indicators.

A ScreenRow may now carry a button whose action returns its own undo,
rather than being snapshotted beforehand like a toggle or field: the
undo for an insertion cannot be described until the thing exists.
Deleting restores at the original index on cancel, not merely
re-appends. Buttons that would act on nothing are not drawn, so ⏎ never
lands on a silent no-op, and destructive buttons name their target on
screen ([- del] Aetherfall) so the row says what it will remove.

Delete is undo-only with no confirmation step: nothing reaches disk
until Save, Esc already replays the undo log, and a second modal state
inside a screen that already has one would double the key-routing rules
for a change that is reversible anyway.

Route radios and the highlight swatches are fields on the rule's own
row, not new rows -- a radio group and a swatch pair are one setting
each, so giving them a row apiece would put N cursor stops in front of
one value. The radios follow the buffer rather than config so ↑↓ visibly
move the dot before ⏎, instead of the group looking inert exactly while
in use.

Colour is a named palette rather than an RGB picker, but the validator
deliberately accepts more than the palette offers -- #rrggbb, idx:N and
none all commit -- because a TerminalColor already in config may be a
colour no short palette names, and a picker that refused the value it
was displaying would make an existing highlight uneditable.

Fixes the footer lying mid-edit: it read [⏎] Save while ⏎ was committing
a field. Focus is threaded through all six FooterLines, and a new test
pins header and footer agreeing about whether an edit is open, in both
directions so it cannot pass by naming no key.

Two pinned row counts in ScreenModelTests legitimately changed --
{2,2,1} to {4,5,1} and {2,0,0} to {4,1,0} -- because button rows are
real navigable rows. Buttons append after each list, so every index
those tests address still means what it did.

Also fixes the cursor doubling as the selection: moving onto [+ world]
pushed the selection past the list, blanking the detail column. Cursor
and selection anchor are now separate.

844 tests pass, up from 811. Eight consecutive Tui runs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The route field was a closed Choice over SpawnTargets(), which is
"distinct spawn-window targets referenced by any trigger" -- so the only
windows offerable were ones some trigger already routed to. There was no
way to create a new spawn window from the settings screen at all.

It is now free text with the known windows as ↑↓ suggestions, following
the pattern the colour picker already set: the palette is what ↑↓ steps
through, but the validator deliberately accepts more than the palette
offers. A window name is a tab title, so it is refused when blank or
carrying control characters, and is otherwise whatever the user calls
it.

Fixes the display bug that fell out of the change: the radios light the
row whose label equals the buffer, so a name being typed for the first
time matched none of them and was invisible -- the group sat with no dot
lit while the keyboard was plainly doing something. A buffer matching no
known window now gets its own row carrying the caret, so what is being
typed appears where the committed value will.

Replaces the test that pinned the old rule (an unknown route refused)
with three: an unknown name commits, blank and control characters are
refused, and a first-time name is visible while being typed.

846 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every screen drew "label   value" identically whether or not you could
change it, so the only way to learn what was editable was to walk the
cursor into it. F5's security, password and session rows looked exactly
like host and port; F2's highlight indicator and F9's auto-start box
looked like toggles you could press and were not.

The field well is now the affordance, drawn at rest rather than only
mid-edit: an editable value sits on FieldBg, a value you cannot change
where it is drawn gets muted ink and no well. Defined once in
ScreenChrome.Field/ReadOnly so no renderer invents its own. Opening a
field keeps the same well and adds the caret, so ⏎ deepens what is
already on screen instead of conjuring it, and nothing shifts.

Scoped to label/value rows -- checkboxes and radio groups already carry
their own affordance.

The two derived indicators stop pretending to be checkboxes:

- F2's "highlight line" becomes a muted caption on the highlight
  section, and moves above the two swatch rows it summarises. It sat
  below the values it derived from, which read backwards.
- F9's "auto-start on connect" merges into the format row. That one was
  genuinely pressable; the bug was two rows owning one stored value, so
  setting format to None silently unchecked a box three lines down. It
  is now one row: Space starts and stops logging, ⏎ picks the format.

Drops the "‹ back" affordance, which appeared only on F7-F9 and pointed
nowhere -- there is no navigation stack, Esc closes. Footer context
lines are now one shape across all eight screens: position plus the one
qualifier identifying the selection, with a test pinning it.

857 tests pass, up from 846. Four existing assertions were re-aimed
rather than deleted, each pinning behaviour this change removes on
purpose; three are inverted so the old behaviour cannot return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps from the design review.

Every list screen drew the item's name as its primary identifier and
none let you change it -- you could edit a trigger's regex but not what
it was called, four times over. Name is now the first field of every
list row on all five screens, so ⏎ on a row (including one just
created) opens the value that identifies it.

Names are deliberately not unique: nothing keys off them, the engines
match on patterns and are keyed by Macro.Key, and two sets may each
hold a rule called Tell. Refusing that would be a rule the config
doesn't have. Blank and control characters are refused, since a name is
drawn into one row of a fixed-width list.

Add/remove existed only on F5, so you could create a world but not a
trigger, alias, timer or macro. All four now have it, following F5's
shape: the action returns its own undo, deleting restores at the
original index, buttons that would act on nothing aren't drawn, and
destructive ones name their target.

Per-screen judgement rather than uniformity: F2 and F3 get duplicate (a
rule is a regex plus route plus two colours plus three flags -- get one
right, copy it per channel), F6 doesn't (three values, two of which you
would change anyway), and F4 must not -- a macro is identified by its
Key, MacroEngine is a dictionary, so a copy would land on the original's
key and never fire. A new timer starts disabled, being the only one of
the four that acts unprompted.

Replaces the undiscoverable End binding with Delete on the row itself,
running that pane's own remove button, and advertises it: "Del remove"
is derived from the model, pinned if-and-only-if so a screen cannot
claim the key without offering it.

Also makes snapshots deterministic. --snapshot rendered whatever config
was on the machine, falling back to the demo only when no worlds
existed, so a saved config silently replaced the demo data -- a golden
file that changes with the developer's own worlds isn't one. It now
always renders the demo; --live-config opts into the real config for
debugging. Verified the same command produces an identical frame with
and without a user config present.

894 tests pass, up from 857. Four pinned row counts changed and were
strengthened rather than renumbered: each now asserts ListSizes, which
still carries the original meaning, alongside the new total. Field
ordinals moved behind named constants so they cannot drift silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half of what a trigger can do was unreachable. TriggerActions.Rewrite,
SendResponse, ScriptCallback and AddAttributes had no UI at all, and
Trigger.CaseSensitive had none either despite F3 exposing exactly the
same setting on aliases. The README advertises "gag / highlight /
rewrite / respond / spawn-route / script"; three of those six could not
be reached.

All five are now editable, grouped into captioned sections so the fuller
pane stays readable: name and pattern, route, highlight (colours plus
attributes), actions (rewrite, respond, script), then the flags.

AddAttributes is a multi-select rather than a cycling choice, since
bold-and-underline is not one-of-N, and it carries no Choices precisely
so the chrome does not advertise ↑↓ that would do nothing. A drawn
legend names the legal words instead and follows the buffer while open.

Script callbacks cannot be enumerated from the scripting host -- its
callbacks are anonymous Lua functions keyed by generated ids, written
into runtime triggers rather than the config this screen edits -- so the
field suggests the callbacks the configuration already names.

Trigger.CaseSensitive drops the cached compiled Regex in its setter, the
same trap Alias.CaseSensitive and Trigger.Pattern had; pinned in both
directions in Core and through the screen.

Also replaces the route-to radio group with an editable value, as asked.
The window is a name you type -- the spawn windows that exist are
defined by what routes to them -- so a radio group was the wrong shape
for it: five rows, the only control in the pane not drawn like its
neighbours, and unable to show a name typed for the first time without a
row invented for the purpose. The windows already in use remain ↑↓
suggestions while the field is open, which is what the group was really
offering. RouteRow is deleted rather than left unused.

Two assertions pinning the radio rendering were re-aimed at the field:
one still asserts the drawn value follows the buffer rather than config,
the other that suggestions are not drawn at rest.

Corrects the earlier snapshot flag: --snapshot renders your own config,
and --demo-config opts into the demo worlds. Defaulting to the demo made
the app's default its demo state. CI and tools/make-screenshots.sh now
pass the flag explicitly, since both depend on the demo data.

912 tests pass, up from 894.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes from the design review, both about F5 holding settings that
were misplaced elsewhere or not reachable at all.

F9 edited one specific character's logging and never said which:
ActiveLogging() resolved the active character or else the first one
configured, so the same screen changed a different character's log
depending on what happened to be connected. Log format and folder are
now fields on the character's own row, drawn in the CHARACTER form under
the heading that names them, with the row itself saying "this character
only".

F9 still opens -- it seeds F5 on the character pane rather than being a
second surface to keep in sync, so the key keeps working without
duplicating a screen. HeaderLine takes the key it was opened with, since
a header hard-coded to F5 would name a key that reopens rather than
closes an F9-opened screen.

ActiveLogging()'s fallback also made a disconnected client show the
first character's LOG html in the status bar; it now reports off.

The security row was a read-only summary of two booleans with no UI.
They are now real checkboxes in a fourth pane, appended rather than
inserted beside the world they describe, because a pane index is a
cursor coordinate and inserting one renumbers every stop the screen and
its tests navigate by.

"accept invalid certificates" is drawn in the warning colour, with the
marker a refused value gets, only while it is both checked and
encrypting -- and it names the consequence rather than restating the
label. A warning that also fired on an unencrypted connection would
train the eye to skip the one case that matters.

929 tests pass, up from 912. Eight pinned assertions changed, each
reported and each strengthened rather than renumbered: the pane-shape
ones now assert ListSizes alongside the new totals. Five tests covering
the deleted F9 options screen were migrated to F5, not dropped.

Known limitation: at 100x24 F5 is over-full and the character table
clips. The edit band already crowded that column; this made it two rows
worse. Collapsing or scrolling that band is its own piece of work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replacing F2's route radios with a plain field fixed a closed-list
problem and created a discoverability one: at rest the field showed only
"Chat", and ↑↓ cycled the known windows one at a time without ever
revealing that main, pages and trade existed. Radios over-constrained; a
bare field under-shows.

A field with known options now draws them while it is open. Built once
in ScreenChrome, so all eleven such fields gained it -- route, both
highlight colours, script, log format, encoding, ambiguous width,
newline key and dictionary (the last two turned out to carry no choices
at all, so Text gained an optional list).

Typing filters, but a buffer that names a choice keeps the whole list:
a field opens on its committed value, so a plain filter would collapse
to one entry the instant it was drawn. A filter matching nothing says "a
new value is allowed" on an open list and "nothing matches" on a closed
one, in neither case using the warning ink -- refusal belongs to the
validator at ⏎.

↑↓ walk the narrowed list rather than cycling blind, and the highlight
and the buffer are one thing rather than two cursors, which would give ⏎
two meanings inside the only modal state these screens have.

It overlays rather than pushes rows down, which was forced: the worlds
view sizes a grid row from its line count, so a growing list would
resize the screen on ⏎, and F2's editor is already tall enough that
pushed rows fall off the bottom. It opens upward when there is no room
below.

Closed lists say "these values only", open ones "suggestions", drawn
from the field's own validator rather than a flag a renderer sets.

Checked the framework first, as asked: SharpConsoleUI does have a
DropdownControl, and it cannot serve this. Its value is an index into
its items, typing is a timed prefix jump rather than entry, and there is
no path to a value outside the list -- it is a picker, not a combobox.
Route exists precisely to name a window that does not exist yet. Using
it for the closed fields alone would also mean rebuilding those panes as
control trees, when the whole settings architecture is pure renderers
producing markup with Render(...) merging for tests.

946 tests pass, up from 929. Two pinned assertions changed, both direct
consequences of ↑↓ walking a filtered list, and both strengthened: one
now asserts which entry is marked rather than merely present.

Known cosmetic wart: a dropdown covering through F2's attrs row leaves
the second line of its two-row legend orphaned below the shadow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audited every editable field on F2-F9 against its runtime consumer
rather than against whether it persists. A checkbox that saves a bool
nothing reads is still a lie, just a durable one.

The systemic finding: StartAsync opened an anonymous session -- no
character, no trigger sets, no log sink -- so F2's triggers, F3's
aliases, F6's timers and every character setting were unreachable at
runtime however correct Core was. The shell now connects as the world's
first configured character, which is what made most of the rest
reachable at all.

Wired nine: strip incoming ANSI colour, allow blink, underline
hyperlinks, emoji substitution, local echo, keep per-tab drafts, world
encoding (now reaching CHARSET negotiation rather than a status-bar
label), the log sink, and timers -- nothing had ever realised a
TimerDefinition, so a configured timer had never fired.

Settings are held by reference and read per line rather than copied at
construction; copying them is exactly how a checkbox comes to need a
restart.

Removed four controls whose features do not exist: ambiguous width (all
width measurement is the framework's Wcwidth, which exposes no
East-Asian policy), newline key (the prompt is single-line), check
spelling and dictionary (there is no spellchecker anywhere in the repo).
A control for a feature that doesn't exist should go, not sit there
looking operable. Old configs carrying those keys still load.

Two left, with reasons rather than pretence. F4's macros cannot fire:
SharpConsoleUI's input parser never produces NumPad keys, so that half
needs DECKPAM/SS3 decoding upstream. Keepalive has no mechanism at all
-- a correct one needs a raw IAC NOP path the telnet session doesn't
expose.

999 tests pass, up from 946, with 53 added. Every wired field has a test
that changing it changes behaviour, most flipping the setting on an
already-connected session, which is what tells Live apart from
applied-at-startup.

Six pinned assertions changed, each reported: the removed rows' counts,
and label assertions turned into absence assertions so the controls
cannot drift back silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MacroEngine.Resolve and HandleKeyAsync existed and were unit-tested, and
nothing in the app had ever called either -- so no configured macro had
ever sent a command, on any key. Dispatch now runs from the main
window's preview handler: before the focused control so a binding beats
the prompt, not raised while a modal holds the keyboard, and after
global shortcuts so app-claimed chords never arrive.

MacroEngine keyed its dictionary on the descriptor string, which is a
cache of Macro.Key -- a rebound macro would have answered to its old key
until reconnect. It now reads Key per lookup.

Which chords can actually arrive is read out of the framework's input
parser rather than assumed, and the screen says so: the whole numpad
never arrives (no DECKPAM is sent and SS3 decodes only a fixed set), so
those rows are drawn muted behind a warning marker with captions naming
the reason. Ctrl+F1 survives the parser fully and is the one live
binding in the demo config.

A key is now rebindable through a capture mode -- the next keypress
becomes the binding -- rather than by typing a key name as text. Esc is
the only non-candidate so capture cannot trap you, and a duplicate is
refused with capture still armed rather than created, because a second
macro on one key is the one dead row with no symptom: both look alive.

Also fixes the add button, which the previous pass flagged rather than
changed. It claimed the lowest free numpad digit -- provably dead now
that the delivery verdicts exist -- so a new binding was born unable to
fire. It claims from MacroKeys.Bindable instead, whose entries are
filtered through the same verdicts the screen draws from, so the
candidate list cannot drift from what actually works.

1071 tests pass, up from 999. Three pinned assertions changed, all
predicted and all pinning the numpad claim this removes; they now assert
against Bindable and additionally that the claimed key fires.

Still owed: none of this has been seen in a real terminal. The verdicts
are read from the parser and asserted, the frames are headless.
TriggerSet is the organising unit of the automation config -- triggers,
aliases, timers and macros all live in one, and characters are assigned
them by name -- yet nothing created, renamed or deleted a set, and
nothing moved an item between them. The only set operation in the whole
UI was toggling a character's membership.

Rejected the obvious shape, a set switcher on F2/F3/F6/F4. A new pane on
four screens renumbers every pane index those screens, their snapshot
key scripts and their tests navigate by; "the current set" has no
natural home; and it hides something true, because a session runs the
union of a character's sets, so the flattened list is the only view
showing every rule that can actually fire. It also would not have solved
moving an item, which is what actually made a mis-filed trigger
permanent.

Instead: sets are objects on F5, and "which set" is a field everywhere
else. F5's third pane was assignment-only and now owns the sets --
Space still assigns, ⏎ renames, ⇥ edits the description, and it has
add/remove. The other four screens get a set field appended last on each
item's row, so moving something is an edit like any other, and nothing
was renumbered.

A set name is the one unique name on these screens, because it is a key:
ResolveTriggerSets takes the first match, so the second of two identical
names could never be assigned.

Renaming rewrites every character's assignment in place, keeping the
order that decides which set wins a conflict. Deleting strips the
references too, and its undo restores the set at its index and each
reference at its own index inside the character that held it -- walking
detach backwards and reattach forwards so two references in one
character survive.

An empty set is visible in both views: a row on F5, and a muted line
naming it on each flattened screen, drawn as markup rather than a row so
it costs no cursor stop.

Also fixes a clipping defect the screenshots caught: with the set cell
added, F4's armed key-capture row ran off the right edge, its prompt
being twice the width of the key it replaces.

1101 tests pass, up from 1071. Four pinned assertions changed, each
reported and each asserting more than before; the pane-shape one keeps
its original ListSizes assertion alongside the new total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last item of the design review, re-judged against the current
screens rather than the ones the review described -- the findings had
partly inverted. The dead space was real but the sharper problem was
that the fixed 56/64 split now starves the other column: at 100x24 F2's
editor clipped at "respond (off)", losing three cursor stops, and F5
clipped one line below its CHARACTERS heading, hiding both characters
and all three buttons, while the list column beside them sat empty.

Three pure rules in ScreenChrome, and a frame the four list screens
share: a column keeps its designed width when the screen affords it and
gives cells back when it doesn't; blocks drop their blank separators
before anything else; and what still doesn't fit is windowed around the
cursor with the edges naming what is off-screen. Bodies size to their
content with a spacer beneath, so the hairline ends where the columns
do, as F7/F8's card already did.

F5 loses its title strip and its CHARACTERS table becomes a selector.
Both restated what the rows below them already showed -- the strip's
every token appeared in the five editable fields under it, and the
table's columns were the character form and the trigger-set pane drawn
again.

F4 sizes its numpad from the longest command it actually holds instead
of truncating to a fixed cell while the column beside it showed the same
value in full, and yields that width while a key capture is armed, the
prompt being twice as wide as the well it replaces.

Each list screen gained a key for its flag marks, which had none
anywhere; the marks the selected row carries are lit, so it reads as
both a legend and a gloss on the cursor's row -- which also answers the
cryptic "on" column header.

F5 at 100x24 is correct rather than complete: 17 rows of detail still
don't fit in 13, but no cursor stop goes undrawn now, which was the
actual bug. A genuinely complete view needs collapsing panes, which
remains its own piece of work.

Kept F2's attribute legend at rest rather than making it conditional.
The orphaned-legend-line case needs a buffer matching nothing, is
transient and self-heals on the next keystroke; the legend is the only
written record of the vocabulary that field accepts, since it carries no
dropdown of its own.

1119 tests pass, up from 1101. Two pinned assertions changed, both
strengthened: one now asserts TLS is stated exactly once and in the
control that sets it, the other pins where each character fact lives
rather than merely that it exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit recorded keepalive as inert with no mechanism to build on.
That is no longer true: TelnetNegotiationCore PR #52 adds an opt-in
idle-reset IAC NOP keepalive with a configurable TimeSpan interval.

Records why the field still cannot be wired -- the API doesn't exist in
the pinned 2.5.3, and unlike SharpConsoleUI it can't be reached through
a conditional project reference, because that only works when both paths
expose the same API and here the package path wouldn't compile. So it
waits on the release rather than on more design, and the note says what
to do when 2.6.0 lands.

Also records the limitation the eventual wiring inherits: IAC NOP proves
the socket write succeeded, not that the peer answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TelnetNegotiationCore 2.6.0 shipped the keepalive, so the field that had
been inert since it was drawn now does something: a world's keepalive
seconds becomes the idle window an IAC NOP goes out on, keeping a NAT or
load balancer from evicting a connection that is merely quiet.

ResolveKeepalive turns configured seconds into the interval. Zero is how
the config spells "off". Anything past the library's 24-hour maximum is
clamped rather than thrown, because the value can be hand-edited and
refusing to connect over it would be the worse answer.

There is deliberately no clamp against the library's one-second minimum.
Writing the test found the guard could never fire -- this setting is a
whole number of seconds, so every value that isn't already "off" is at
least one -- and an unreachable branch claiming to be a safety net is
worse than none. The test now pins the property the resolver relies on
instead of the clamp it doesn't need.

Applies at connect rather than live, and the docs say so: the library's
KeepAliveInterval is init-only because the idle loop reads it once when
it starts. That puts it with host, port, TLS and encoding rather than
with the settings held by reference and read per line.

What it does not do is also recorded: IAC NOP proves our write
succeeded, not that the server answered, and being unnegotiated there is
no way to detect a peer that mishandles it. TIMING-MARK (RFC 860) is the
option that would verify the peer and is not implemented upstream.

1129 tests pass, up from 1119.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HarryCordewener
HarryCordewener merged commit 06d25c6 into main Jul 29, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/settings-panel-treatment branch July 29, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant