diff --git a/CLAUDE.md b/CLAUDE.md index 022f113..6311e3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,6 +302,8 @@ Both (2) and (3) **must come from the page**, and this is the part that is easy - **WebView2 is an `HwndHost`.** Mouse input landing on hosted native content raises **no** WPF routed events, tunnelling `Preview*` ones included. A `PreviewMouseLeftButtonDown` on the host Border only ever fires for the thin ring around the terminal (#108). The same fact bites on the way out too — WPF content cannot be *drawn* over a pane either, whatever `Panel.ZIndex` says. See "Session Spinners". - **xterm's `onData` is not "the user typed".** It also carries replies the terminal generates itself — device attributes (`ESC[?1;2c`), cursor-position reports, OSC colour replies, focus in/out (`ESC[I`/`ESC[O`) — plus mouse reports when the app enables tracking. Filtering those by inspecting the bytes cannot work; a device-attribute reply is not distinguishable from typing by shape. xterm knows internally (`triggerDataEvent`'s `wasUserInput`) but does not expose it on `onData`. `onKey` is the only honest source (#106). +**Setting `ActiveSession` moves the highlight, not the keyboard.** Keystrokes follow Win32 focus, so every path that changes the active session on the user's behalf must also call `FocusTerminal()`, or the highlight and the keyboard drift apart and typing lands in a different pane than the highlighted one. Three places enforce it: a pane click (`PaneActivated` promotes *and* focuses), restart (refocuses the revived session), and `OnWindowActivated`, which refocuses the previous session only when **no mouse button is down**. A click-to-activate is the user choosing the target, and the old unconditional refocus raced the page's `activate` message and sometimes won. + The page-side `mousedown` handler also calls `doFit()`, and the initial fit is re-run on `document.fonts.ready`. xterm derives its column count from the *measured advance width* of the font, so a fit that runs before the font loads computes the wrong `cols` and tells the PTY a width that doesn't match what is drawn — text then overlaps mid-line. The `ResizeObserver` cannot catch that, because the element size never changed, only the glyph metrics (#113). ## A fit that changes nothing must still report the size diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index f643d89..cfa170e 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -5394,6 +5394,10 @@ private async Task RestartSessionCoreAsync(SessionViewModel vm, string? launched { _vm.ActiveSession = revived; UpdateSidebarActiveState(); + // ActiveSession alone moves the highlight, not the keyboard. The old pane's + // WebView2 is gone, so without this focus is left wherever the teardown put + // it — and typing lands in a pane other than the highlighted one. + revived.Bridge?.FocusTerminal(); } } } @@ -6286,11 +6290,35 @@ private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods) // Refocus the last-active terminal when the window regains focus (e.g. Alt+Tab). // Without this, focus lands on whichever WPF control happened to hold it last — // typically not the WebView2, so keystrokes go nowhere. + // + // But NOT when the window was activated by a click. Then the click is the user saying + // where focus goes: on a pane, the page's mousedown posts `activate`, and + // MainViewModel promotes AND focuses that pane; on the sidebar, FocusSession does it. + // Refocusing the *previous* ActiveSession here raced that: FocusTerminal is a + // BeginInvoke, the page's `activate` is a cross-process message, and when the stale + // focus landed last the highlight moved to the clicked pane while the keystrokes went + // to the old one — "I type in one pane and the letters appear in another". private void OnWindowActivated(object? sender, EventArgs e) { + if (IsAnyMouseButtonDown()) return; _vm.ActiveSession?.Bridge?.FocusTerminal(); } + // GetAsyncKeyState reads the PHYSICAL buttons, so a swapped-button mouse reports its + // primary click as VK_RBUTTON — check both rather than guess which one is "left". + // WPF's Mouse.LeftButton is not usable here: a click on a WebView2 (an HwndHost) is + // native input WPF never sees. + private static bool IsAnyMouseButtonDown() + { + const int VK_LBUTTON = 0x01, VK_RBUTTON = 0x02, VK_MBUTTON = 0x04; + return (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0 + || (GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0 + || (GetAsyncKeyState(VK_MBUTTON) & 0x8000) != 0; + } + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + private void CycleSession(bool forward) { if (_vm.Sessions.Count < 2) return; diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 995d4b5..c965b96 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -335,7 +335,18 @@ void Promote() vm.Bridge.KeyboardInput += Promote; // Clicking into a pane must promote it too. This can only come from the page — // see TerminalBridge.PaneActivated for why WPF never sees the click. - vm.Bridge.PaneActivated += Promote; + // + // And it must take keyboard focus with it. Promote moves the highlight; the + // keyboard follows Win32 focus, which something else can hold — the window's + // Activated handler refocusing the previous session, or a restart's teardown. + // A click is the user saying where input goes, so make focus agree with the + // highlight. Throttled page-side (300ms); FocusTerminal is one BeginInvoke. + var bridge = vm.Bridge; + vm.Bridge.PaneActivated += () => + { + Promote(); + bridge.FocusTerminal(); + }; // UserInput is every onData, which includes xterm's own automatic replies // (device attributes, cursor reports, focus in/out). For the heuristic that has // always been the "interaction" signal; for an OSC 7501 session it must not be,