anvilsign in

collin/browser-terminal-extension · 70c84263

Offer Ctrl+N, Ctrl+T and Ctrl+W on Ctrl+Alt

Collin Richards · 2026-08-18 07:54 UTC · 70c842632d07d4d70bab99227471661396da8d00 · parent 09d8336c · browse files

modifiedREADME.md+38 −0
⋯ 588 unchanged lines
589589 connection carries three commands (open, focus, close) and nothing else — no
590590 terminal traffic goes through it, for the reason in the header of `sidebar.js`.
591591
592+## Keys the browser takes first
593+
594+Most browser shortcuts are cancelable: the page sees the keydown, calls
595+`preventDefault()`, and the browser drops it. That is why Ctrl+P, Ctrl+F,
596+Ctrl+S and friends reach the pty like any other key.
597+
598+Three do not. **Ctrl+N**, **Ctrl+T** and **Ctrl+W** are *reserved*: the browser
599+process acts on them before the keystroke is handed to the renderer, so the
600+panel never receives an event and has nothing to cancel. Extension commands
601+cannot claim them either. `chrome://extensions/shortcuts` refuses reserved
602+combinations for the same reason. Nothing inside the extension can win them
603+back.
604+
605+So they are offered one modifier over, and only while the terminal has focus:
606+
607+| Press | Sent to the pty |
608+|---|---|
609+| **Ctrl+Alt+N** | `^N` |
610+| **Ctrl+Alt+T** | `^T` |
611+| **Ctrl+Alt+W** | `^W` |
612+
613+The bytes are written straight to the socket, so tmux and readline see exactly
614+what a real Ctrl+N would have produced.
615+
616+**If you want the real keycap.** The browser cannot be talked out of Ctrl+N,
617+but a remapper below it can make sure the browser never sees the key. Bind
618+Ctrl+N to Ctrl+Alt+N and the panel picks it up as above:
619+
620+- [`xremap`](https://github.com/xremap/xremap) can do it per-application, so
621+ Ctrl+N still opens a window everywhere else. On GNOME/Wayland it needs the
622+ companion shell extension to know which window is focused; on X11 it does
623+ not.
624+- [`keyd`](https://github.com/rvaiya/keyd) sits at evdev and is simpler to run,
625+ but it is system-wide: Ctrl+N stops meaning "new" in every other app too.
626+
627+Both are outside this repo, and neither is required. Ctrl+Alt+N works on its
628+own.
629+
592630 ## Picking elements off the page
593631
594632 Two ways to start it, and the difference matters:
⋯ 249 unchanged lines
modifiedextension/sidebar.js+36 −1
⋯ 248 unchanged lines
249249 return true;
250250 }
251251
252+/** Letters the browser reserves for itself, offered on Ctrl+Alt. */
253+const STOLEN_KEYS = "ntw";
254+
255+/**
256+ * The keys the browser keeps for itself, one modifier over.
257+ *
258+ * Ctrl+N, Ctrl+T and Ctrl+W never arrive here at all. They are *reserved*
259+ * accelerators: the browser process acts on them before the keystroke is
260+ * handed to the renderer, so unlike Ctrl+P or Ctrl+F there is no event in this
261+ * document to cancel, and no extension command can claim them either: the
262+ * shortcuts page refuses them for the same reason. A panel that is a terminal
263+ * wants all three (^N is next-history and tmux's next-window, ^T transposes,
264+ * ^W kills a word), so they are offered on Ctrl+Alt instead: Ctrl+Alt+N puts a
265+ * literal ^N in the pty, Ctrl+Alt+W a ^W, and so on.
266+ *
267+ * This is also where a system-level remapper should aim. Nothing inside the
268+ * browser can win Ctrl+N back, but a remapper below it can. Bind Ctrl+N to
269+ * Ctrl+Alt+N while the browser is focused (xremap can do it per-application on
270+ * GNOME; keyd system-wide) and the keycap you press is the key you meant.
271+ *
272+ * Returns true if the event was one of ours and has been handled.
273+ *
274+ * @param {KeyboardEvent} e
275+ */
276+function handleStolenKey(e) {
277+ if (!e.ctrlKey || !e.altKey || e.metaKey) return false;
278+ const letter = e.key.length === 1 ? e.key.toLowerCase() : "";
279+ if (!STOLEN_KEYS.includes(letter)) return false;
280+ e.preventDefault();
281+ // Silent when there is nothing to type into: the alternative is a log line
282+ // per keystroke while the panel is offline.
283+ if (connected && ws) ws.send(enc.encode(String.fromCharCode(letter.charCodeAt(0) - 96)));
284+ return true;
285+}
286+
252287 /** @param {string} pref */
253288 function setTheme(pref) {
254289 themePref = Themes.PREFERENCES.includes(pref) ? pref : "auto";
⋯ 59 unchanged lines
314349 term.attachCustomKeyEventHandler((e) => {
315350 if (e.type !== "keydown") return true;
316351 if (staleRepeat(e)) return false;
317- return !handlePanelKey(e);
352+ return !(handlePanelKey(e) || handleStolenKey(e));
318353 });
319354
320355 $("term").addEventListener(
⋯ 5345 unchanged lines