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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,73 @@ fallbacks) for inline images/maps.
- **The demo scene loads with `_watching` off.** It pours a spawn window's whole history in before the
first frame, and every line would otherwise count as missed — so any frame that later made such a
window visible carried an activity bar reporting the client's own setup as news.
- **A pane's output can be selected with the mouse and copied with `⌃C`, and almost all of it is the
framework's** (`MarkupControl.EnableSelection`, shipped in the pinned 2.5.14 and **off by default**).
Drag, double-click word, triple-click line, drag-autoscroll through the `ScrollablePanelControl` each
pane already sits in, one-selection-per-window arbitration and a wrap-aware copy all come from the
package. This is the rare case where the answer was to *switch something on* rather than build it; what
had to be ours is the colour, the clipboard, and what happens to a selection when the buffer moves.
- **The terminal's own selection cannot do this job, which is `UrlDetector`'s argument one layer over.**
Under `?1003` — which this client needs for the wheel, tab and rail clicks and pane drag-and-drop — a
plain drag belongs to the application, and the emulator's escape hatch (kitty's ⇧-drag) selects a
terminal **row**: on a vertical split that row crosses the divider into another pane's output, and
since a pane is narrower than the row a logical line wraps and comes back with newlines injected at
the wrap points. The framework's copy walks the painted cells and breaks a line only where a row is
not a soft-wrap continuation. There is no partial retreat from `?1003` that keeps the wheel.
- **The selection band is one pair per theme, and for a different reason than `ReadingPlane`'s.** There
it is cost; here it is meaning. A pane's plane already says whose connection this is (hue) and where
the keyboard is (luminance), and a selection is neither — it is the client answering a gesture being
made *now*, in exactly one pane at a time, so a highlight that changed colour with whose pane it
landed in would report a fact nobody asked about. `WorkspacePalette.SelectionBand` is `ReadingPlane`
pushed *further* in the direction of travel — brighter on a dark theme, darker on a light one — then
leaned toward `Theme.Prompt`, the same anchor `ArmedBand` uses. It is held to a **fill** floor against
all fourteen planes (1.5:1; tightest measured 2.70:1) and `SelectionInk` to `Contrast.Floor` on the
band, which matters more here than anywhere: the highlight replaces the world's *foreground* too, so
that one ink is what all selected output is read in.
- **The clipboard writer is caller-supplied and null by default** — the `save:`/`logRoot:`/`openUrl:`
family, and here it also buys a *single* copy path. The framework's own ⌃C writes straight to the
system clipboard through a static helper no caller can substitute, so a test run would replace
whatever the developer had copied and the one path that could not be injected would be the one under
test. `CopyEnabled = false` on the pane controls and the chord is answered by
`SharpMUTermApp.CopyFocusedSelection` instead; `Program` supplies `ClipboardHelper.SetText`, which
covers OSC 52 *and* the platform tool, so a copy lands locally and over ssh alike.
- **⌃C is claimed in the main window's key chain, not in `MacroKeys.AppShortcuts`.** A global shortcut
runs ahead of *every* window including the composer, whose `MultilineEditControl` has its own ⌃C and
is a real editor. Being in the chain also puts it after `DispatchMacro`, so a macro bound to ⌃C wins —
the same relationship ⌃←/→ has with pane selection, and the reason `Verdict` needs no special case.
- **A selection is dropped whenever the rows under it move, and the clear belongs in `FeedRange`** — the
one function that actually replaces a pane control's content. `RepaintPane` is *not* the only caller:
`BuildFrozenContent` feeds both halves of a frozen pane and `ToggleFreeze`'s thaw branch pours the whole
buffer back, so clearing at the repaint site alone left freeze and thaw re-feeding under a live
selection. The thaw is the case with teeth — freezing leaves the live control empty, so a stale anchor
merely yields nothing, while after a thaw the rows exist again and ⌃C hands over real text nobody
dragged across (`FreezingAndThawingDropsTheSelectionRatherThanReAnchoringIt` copied `The Grand Plaza…`
before this moved). **`MarkupControl.SetContent` does not clear a selection** — only its append path
does (`OnContentAppended`) — so this cannot be left to the framework.
- **The copy asks the window's `SelectionManager`, never the focused pane.** That manager owns the one
active selection and clears the previous owner when a new one starts. The focused pane is the wrong
question: pane selection moves on ⌃arrows, ⌃O and a tab click, and a press in a pane's *body* moves
none of them — so a drag in the pane beside the focused one left the copy looking elsewhere and
reporting `nothing selected`, which reads as a feature that does not work. It also retires the special
case for a frozen pane: one selection, one owner, whichever control that is.
- **A pane id and a window id are different namespaces that are both strings**, and this is where that
bites: `PaneOutputRects` is keyed by *pane*, while `SimulatePaneDrag` and `PaneSelection` take a
*window*. `PaneWindows()` maps between them, and exists because a test that passed a pane id to the
drag seam selected nothing — indistinguishable from the feature being broken.
- **`NewPaneControl` is the one place a pane's control is made**, and it exists because enabling
selection on `PaneContentFor` alone left the **main** window — the pane most people are looking at —
unable to select anything. That control is built in the constructor, before a workspace exists; every
other one is built on demand. Two creation paths, and the trap announced itself immediately.
- **`SimulatePaneDrag` is the test seam**, for `SimulatePaneClick`'s reason: the framework registers its
driver-mouse handler inside `Run()`, which no test calls. The drag flag rides *with* the button flag,
because SGR encodes motion-while-held as `Button1Pressed | Button1Dragged` and a seam sending the bare
form would exercise a path the terminal never produces. The `selection` view is the frame, and it is
in `FrameContrastTests`' list — a colour nothing renders is a colour nobody checks.
- **Not fixed, and known**: chrome rows live in the same buffer and are selectable (a terminal selection
would take them too); OSC 52 caps at ~74 KB and `Osc52.BuildSequence` returns null past it, so a very
large copy lands locally and **silently** does not travel over ssh; GNU screen has OSC 52 disabled
upstream; tmux needs `allow-passthrough on`. The Windows mouse path is a separate ad-hoc parser in
`NetConsoleDriver` and nothing here can verify it — treat Windows drag-select as unproven.
- **Coming back to a window you were not watching leaves a bar where you left off, and that covers two
different absences.** The *window* one is `NEW` and is the common case: a line lands while the window
is not `Workspace.IsCaughtUp` — visible **and** at its live tail — and `_missedFrom` records the index
Expand Down Expand Up @@ -539,7 +606,12 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg
a viewport row), `activity-bar` (the *other* absence — a window the reader was not watching: three
lines land in the main window while Chat is in front of it, and picking main back lands on the `NEW`
bar with those three under it. Separate from `away` because the two are separate facts with separate
wording, and this is the one that happens many times an hour), `prefix-panel` (the ⌃B which-key
wording, and this is the one that happens many times an hour),
`selection` (a real ⌃-drag across the main window's output, through `SimulatePaneDrag` and the control's
own hit test rather than a highlight posed by hand — the only frame carrying `WorkspacePalette.SelectionBand`,
which is why it is in `FrameContrastTests`' list: the band is the one plane this client invents rather
than derives from a pane, and its ink replaces the world's own on every selected cell),
`prefix-panel` (the ⌃B which-key
panel — the state `prefix` becomes a few hundred milliseconds later, if no key has arrived),
`focus`/`focus-moved` (a split *and* a second command line — the one geometry showing a focused pane
beside an unfocused one and an armed bar above an idle one, before and after a real ⌃→),
Expand Down Expand Up @@ -724,6 +796,32 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/]
`TryRecallKey` now matches on — **exactly**, so `⌥⇧↑` (the pane resize) still reaches its own
handler. A macro bound to `Alt+Up` wins over recall, because `DispatchMacro` runs first: the same
relationship `Ctrl+←/→` has with pane selection.
- **`⌃N` walks the focused pane's tab strip, and it stays `⌃N` because the familiar spellings do not
exist here.** Asked for "an easy key-combination to tab through the tabs of the active pane", the
obvious candidates were measured at a raw reader with `kitten @ send-key` before anything was built:
`⌃Tab` is `09`, byte-identical to a bare Tab (already in `MacroKeys.ControlBytes`); `⌃⇧Tab` is
`CSI Z`, byte-identical to plain `⇧Tab`; and **`⌥Tab` is `ESC` + `09`**, which is `ESC` + a *control*
byte and so arrives as **two** key events rather than an Alt chord (`AnsiInputParser.ProcessEscape`).
A `TryAltEnter`-style reassembly could pair them, but Tab is already spent as
`TerminalFocusWatcher`'s disguised focus-in — and `send-key` writes into the pty, so it says nothing
about the *compositor*, which takes `⌥Tab` unconditionally on Windows, GNOME and KDE. `⌃PgUp`/`⌃PgDn`
is the one familiar pair that does arrive (`CSI 5;5~` / `CSI 6;5~`, decoded by `DispatchTilde`, and
free because `TryScrollKey` matches PageUp/PageDown only at `ctrl: false`) — kept in reserve rather
than spent, since the reported problem was that `⌃N` could not be *found*, not that it was wrong.
- **So the fix was discoverability, and the chord had to earn it by answering.** `⌃N` was named on F4
and nowhere else. It is now a ⌃P entry (`layout:next-tab`, listed unconditionally like the
directional pane entries, because this surface is where a reader learns a pane holds tabs at all) and
a status-row segment (`⌃N tab`, shown exactly while the focused pane has a second tab, the same
contextual rule its neighbours follow). Listing a key obliges it to answer: `NextWindow` **returned
in silence** on a single-tab pane, which is indistinguishable from a dead key, and now refuses out
loud beside `PrefixPanel.NoCycleRefusal`'s wording. Every surface says **tab**, not "window" — F4 and
`--help` said window while everything else said tab, and `⌥N` already owns the window noun.
- **`FocusHints` separates reading order from drop order.** Three independent conditions is eight
cases, so the ladder is generated; but the row reads `pane · size · line` while *size* is the first
thing surrendered, so a generator that dropped from the end of the reading order would silently
reorder a row nobody asked to reorder. The tab segment is given up second, and that judgement is
written down: a pane's tabs are drawn as a strip you can see, so the hint names a shortcut to
something already visible, while nothing on screen says how to reach another pane or the second bar.
- **Known and not fixed here**: `⌃N` and `⌃O` have no reverse (the character cycle does — `⌥J`/`⌥K`),
and `⌃W` and `⌃B x` are two chords for one action. Both are shape complaints rather than defects,
and both are behaviour changes rather than modifier moves.
Expand Down
3 changes: 2 additions & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ count on the tab, the rail character, and the rail world.

`⌃P` command surface · `⌃F` search the output (`⌥G` next hit) · `⌥F` freeze/resume in focused pane ·
`⌃R` command-history search ·
`⌃N` next window · `⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too,
`⌃N` next tab in the focused pane · `⌃C` copy the pane selection (drag to select) ·
`⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too,
where the caret has nowhere further to go) · `⌥⏎`, or `⌃L`, newline in input · `F1` composer ·
`F2`–`F9` config · `Esc` close overlay.

Expand Down
18 changes: 18 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ public static IReadOnlyList<CommandItem> Build(
"term:restore-purge",
"deletes every pane's saved content"));

// Copying output. Listed unconditionally and subtitled with the *gesture* as well as the chord,
// because the gesture is the part nobody can guess: under mouse reporting a plain drag belongs to
// the application, so a user who has learnt that their terminal needs ⇧-drag has no reason to try
// dragging here. It refuses out loud with nothing selected, which is what earns it a row at all.
items.Add(new CommandItem(
CommandGroup.Terminal, "Copy the selection", "term:copy", "⌃C · drag across a pane to select"));

// The client's own messages — the status-line notices that dismiss themselves — kept out of the
// output window (and so out of the session log) and readable here instead.
items.Add(new CommandItem(
Expand Down Expand Up @@ -250,6 +257,17 @@ public static IReadOnlyList<CommandItem> Build(
items.Add(new CommandItem(CommandGroup.Layout, "Focus pane down", "layout:focus-down", "⌃↓"));
items.Add(new CommandItem(CommandGroup.Layout, "Focus the next pane", "layout:cycle", "⌃O · ⌃B o"));

// The tab cycle, beside the pane cycle it rhymes with. Listed unconditionally for the same reason
// the four directional entries above are: this surface is where the keyboard is learnt, and a
// reader whose panes each hold one window has no other way to find out that a pane holds tabs at
// all. ⌃N has always done this and was named on F4 and nowhere else.
//
// Listing it obliges it to answer, which the directional entries pay for by refusing out loud and
// this one did not — it returned in silence on a pane with one tab, which is what a dead key looks
// like. The refusal is the host's (SharpMUTermApp.NextWindow); the entry is only allowed to exist
// because it is there.
items.Add(new CommandItem(CommandGroup.Layout, "Focus the next tab", "layout:next-tab", "⌃N"));

// Numbered pane jumps, one entry per pane that exists — the one group here that is *not* listed
// unconditionally, because "Go to pane 4" on a workspace with two panes names a place there is no
// way to make. The number is the one the move and drag overlays badge each pane with, so the entry
Expand Down
7 changes: 6 additions & 1 deletion src/SharpMUTerm.Tui/MacroKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ private static AppShortcut[] BuildAppShortcuts()
private static AppShortcut[] Fixed() => new AppShortcut[]
{
new(ConsoleModifiers.Control, ConsoleKey.Q, "asks whether to quit"),
new(ConsoleModifiers.Control, ConsoleKey.N, "picks the next window"),
// "the next tab in this pane", not "the next window", and the wording is the point. A tab *is* a
// window — but ⌥N goes to a numbered window anywhere in the workspace, and this walks the strip of
// the pane in front of you, so two keys described in the same noun read as two spellings of one
// action. F4, --help, the ⌃P entry and the status row all say tab now; they said window here and
// tab everywhere else, which is the drift the numbering vocabularies are kept apart to avoid.
new(ConsoleModifiers.Control, ConsoleKey.N, "goes to the next tab in this pane"),
// ⌃Tab is deliberately absent, and its absence is measured rather than assumed: a terminal writes
// 0x09 for it, byte-identical to a bare Tab (read off a pty with `kitten @ send-key`), so the
// parser reports ConsoleKey.Tab with no Control bit and this claim could never once have matched.
Expand Down
12 changes: 10 additions & 2 deletions src/SharpMUTerm.Tui/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,14 @@ private static int Main(string[] args)
logRoot: logRoot,
restore: restore,
mssp: mssp,
openUrl: ExternalBrowser.Open);
openUrl: ExternalBrowser.Open,

// The one place a real clipboard is reached. SharpConsoleUI's helper writes OSC 52 *and*
// mirrors to the platform tool (wl-copy / xclip / pbcopy / Win32), so a copy lands whether the
// client is local or on the far end of an ssh session. Supplied here rather than reached for
// inside the app for the reason logRoot and the browser launcher are: nothing that is not this
// entry point may touch the developer's clipboard, least of all the test suite.
clipboard: SharpConsoleUI.Helpers.ClipboardHelper.SetText);
var exitCode = liveApp.Run(startup); // blocks on the SharpConsoleUI main loop until exit

// Persist the workspace so the next launch resumes where this one left off.
Expand Down Expand Up @@ -299,7 +306,8 @@ private static void WriteUsage(TextWriter usage)
usage.WriteLine("'at start' only opens the connection. What is typed once one is open follows from the");
usage.WriteLine("character's saved password and connect line — F5's 'login' row says which.");
usage.WriteLine();
usage.WriteLine("In-app: Up/Down history · Ctrl+N next window · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit.");
usage.WriteLine("In-app: Up/Down history · Ctrl+N next tab · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit.");
usage.WriteLine("Selection: drag across a pane's output to select it, Ctrl+C to copy.");
// The composer earns a line of its own because what it *sends* is not guessable from the window:
// the buffer is one command and its line breaks are written %r, which is what a MUSH board or
// mail body wants. Naming the send chord matters for the same reason — Ctrl+Enter is what a
Expand Down
Loading
Loading