From 5862f1235358888a0489596bde83be5419277e94 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sat, 29 Aug 2026 21:13:49 +0200 Subject: [PATCH] fix(terminal): make clicking a pane activate it, from the page side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a terminal has never made it the active session. #93 added a PreviewMouseLeftButtonDown handler on the pane's host Border, but WebView2 is an HwndHost: mouse input landing on hosted native content does not raise WPF routed events at all, tunnelling ones included. That handler only ever fired for the ~2px ring around the terminal, never for the terminal itself. So #93 only ever worked for the sidebar path, and — after #106 — for typing. Reported as "still won't switch tabs in the left menu when I click and put focus on another window", which is exactly right. The click can only be observed from inside the page, so terminal-init.js now posts an 'activate' message on mousedown (throttled to 300ms, since promotion is idempotent) and TerminalBridge raises PaneActivated. MainViewModel promotes on that and on KeyboardInput through one shared handler. The page-side handler also calls fitAddon.fit(). The grid rebuild that used to run on every activation incidentally forced a layout pass and therefore a re-fit; #105 skips that rebuild when nothing visible changes, so the re-fit has to be explicit now. Without it xterm's column count can drift from what the PTY was told, and redraws land a character off — reported as typing starting one character into Claude's placeholder, leaving a stray leading "T" from 'Try "how do I log an error?"'. Corrected the WPF-side comment, which claimed the handler covered clicks in the pane. It never did. 312/312 pass, 0 warnings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be --- src/CodeShellManager/Assets/terminal-init.js | 22 +++++++++++++++++++ src/CodeShellManager/MainWindow.xaml.cs | 12 +++++----- .../Terminal/TerminalBridge.cs | 16 ++++++++++++++ .../ViewModels/MainViewModel.cs | 13 +++++++---- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/CodeShellManager/Assets/terminal-init.js b/src/CodeShellManager/Assets/terminal-init.js index 3fbbfdd..efe5f3f 100644 --- a/src/CodeShellManager/Assets/terminal-init.js +++ b/src/CodeShellManager/Assets/terminal-init.js @@ -58,6 +58,28 @@ window.chrome.webview.postMessage(JSON.stringify({ type: 'userkey' })); }); + // ── "the user clicked into this pane" signal ─────────────────────────────── + // This MUST come from here rather than from WPF. WebView2 is an HwndHost — a + // native child window — and WPF routed mouse events (tunnelling Preview* ones + // included) do not fire for input that lands on hosted native content. A + // PreviewMouseLeftButtonDown on the host Border therefore only ever fires for + // the 2px ring around the terminal, never for a click in the terminal itself, + // so clicking a pane never made it the active session. + // + // fit() here as well: the grid rebuild that used to run on every activation + // incidentally forced a layout pass and hence a re-fit. That rebuild is now + // skipped when nothing visible changes, so re-fit on interaction has to be + // explicit — otherwise xterm's column count can drift from what the PTY was + // told, and redraws land a character off. + var lastActivate = 0; + document.addEventListener('mousedown', function () { + var now = Date.now(); + if (now - lastActivate < 300) return; + lastActivate = now; + try { fitAddon.fit(); } catch (e) {} + window.chrome.webview.postMessage(JSON.stringify({ type: 'activate' })); + }, { capture: true }); + // ── Resize notification ──────────────────────────────────────────────────── term.onResize(({ cols, rows }) => { window.chrome.webview.postMessage(JSON.stringify({ type: 'resize', cols, rows })); diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 88ac440..4a0e99a 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -3753,13 +3753,11 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) Tag = accent }; - // Clicking anywhere in a pane makes that session active. Tunnelling (Preview) - // because the WebView2 swallows the bubbling event before it reaches us, and - // Handled is deliberately NOT set so the click still lands in the terminal. - // - // Without this, ActiveSession only followed sidebar clicks, so a pane clicked - // directly kept IsForeground=false and flushed its output at Background - // dispatcher priority — its own echo queued behind every other session's. + // Fires only for the thin ring/chrome AROUND the terminal — WebView2 is an + // HwndHost, so a click on the terminal itself never reaches WPF as a routed + // event. The terminal case is handled by TerminalBridge.PaneActivated, posted + // from the page. Kept because clicking the border should still activate, and + // Handled is deliberately unset so the click still passes through. activeRing.PreviewMouseLeftButtonDown += (_, _) => { if (!ReferenceEquals(_vm.ActiveSession, vm)) _vm.ActiveSession = vm; diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 082ed45..71a7c24 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -78,6 +78,15 @@ public sealed class TerminalBridge : IDisposable /// public event Action? KeyboardInput; + /// + /// The user clicked into this pane. Posted from the page's mousedown, because + /// WebView2 is an HwndHost: mouse input landing on hosted native content never + /// raises WPF routed events, so a PreviewMouseLeftButtonDown on the host Border + /// only fires for the thin ring around the terminal — never for the terminal itself. + /// That is why clicking a pane did not make it the active session. + /// + public event Action? PaneActivated; + /// /// Fires when the user presses a keyboard accelerator (Ctrl-combo, F-key, etc.) /// while the WebView2 has focus. Subscribers set e.Handled = true to prevent @@ -351,6 +360,13 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived KeyboardInput?.Invoke(); break; + // Posted from the page on mousedown. WebView2 is an HwndHost, so a click + // in the terminal never reaches WPF as a routed event — this is the only + // way the host learns the user clicked into this pane. + case "activate": + PaneActivated?.Invoke(); + break; + case "resize": { int cols = root.GetProperty("cols").GetInt32(); diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 054d72e..6240e5c 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -292,12 +292,17 @@ public void RegisterSession(SessionViewModel vm) // app enables mouse tracking — which Claude Code does. Promoting on those // turned this into hover-to-focus and repainted every pane's border on every // mouse move. Hence the mouse-report filter rather than promoting on any input. - vm.Bridge.KeyboardInput += () => + // Guarded on reference equality: these run per keystroke / per click, and the + // assign fans out to UpdateActiveTerminalHighlight across every session. + void Promote() { - // Guarded on reference equality: runs per keystroke, and the assign fans - // out to UpdateActiveTerminalHighlight across every session. if (!ReferenceEquals(ActiveSession, vm)) ActiveSession = vm; - }; + } + + 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; vm.Bridge.UserInput += () => vm.AlertDetector?.NotifyUserInteracted(); }