fix(desktop): bind terminal tab chords and free Cmd+W

Buzz Term had no keyboard path to its tab actions: `encodeTerminalKey`
rejects every metaKey event and the substrate handled only the Cmd+J
handoff, so `onNewSession`/`onCloseSession`/`onSelectSession` existed as
props with nothing bound to them.

Add a capture-phase chord layer alongside the Cmd+J listener, gated on
`enabled && owner === "terminal"`: Cmd+T spawns, Cmd+W closes the active
tab, Shift+Cmd+Left/Right step between tabs. Matching lives in
`matchTabChord`/`stepSession` as pure functions, mirroring the existing
`matchBackForwardChord` split.

Cmd+W additionally needed the native layer. Buzz never called
`Builder::menu()`, so Tauri auto-installed `Menu::default()`, whose File
and Window submenus each carry a `close_window` item bound to Cmd+W --
and macOS resolves a menu key equivalent before the webview sees any key
event, so no JS listener could ever claim it. That accelerator was also
already wrong on its own terms: `CloseRequested` on the main window is
intercepted and turned into hide-to-tray, so Cmd+W hid the whole app
rather than closing anything, duplicating Cmd+H. `app_menu` now builds
the standard menu minus both `close_window` items, keeping Tauri's
well-known Window/Help submenu ids so `init_app_menu` still hands them
to AppKit.

Control is deliberately excluded as a chord modifier on macOS: Ctrl-W is
werase and Ctrl-T transposes, and consuming them in capture phase would
starve the PTY.

Tests pin each chord through the real window listener, plus the guards a
matcher-only test would miss: chords inert in Buzz mode, Ctrl-W still
reaching the PTY, and no repeat close on an already-closing tab. Six
mutants (dispatch removed, owner gate dropped, closing guard dropped,
Control accepted, closing-skip dropped, direction swapped) each fail at
least one test; the direction pair needs three sessions, since with two
tabs previous and next resolve to the same id.

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-08-02 22:00:33 -04:00
parent 1350422539
commit 3b7a4895ee
6 changed files with 483 additions and 2 deletions
+115
View File
@@ -0,0 +1,115 @@
//! The macOS application menu.
//!
//! Buzz never called `Builder::menu()`, so Tauri installed `Menu::default()`
//! for us (`tauri::app::Builder::build`, macOS arm). That default puts a
//! `close_window` item in both the File and Window submenus, and muda gives
//! that item a Cmd+W key equivalent bound to `performClose:`.
//!
//! Two consequences, both wrong for Buzz:
//!
//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned
//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole
//! app. That is already redundant with Cmd+H (Hide), which stays.
//! 2. macOS resolves a menu key equivalent before the webview receives any key
//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab"
//! while the accelerator was claimed here.
//!
//! So this module builds the standard menu minus both `close_window` items.
//! Everything else matches `Menu::default()` deliberately: the goal is to drop
//! one item, not to design a menu.
//!
//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to
//! restore the item and disable it while the terminal owns input (a disabled
//! item does not consume its key equivalent) -- at the cost of an owner->Rust
//! IPC hop this approach does not need.
#[cfg(target_os = "macos")]
use tauri::menu::{
AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::AppHandle;
use tauri::{Builder, Runtime};
/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise
/// auto-install. A no-op off macOS, where that default is never created and
/// the Cmd+W accelerator does not exist.
pub fn install<R: Runtime>(builder: Builder<R>) -> Builder<R> {
#[cfg(target_os = "macos")]
let builder = builder.menu(build);
builder
}
/// Mirrors `Menu::default()` with every `close_window` item omitted.
///
/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu`
/// looks them up by id to call `set_as_windows_menu_for_nsapp` and
/// `set_as_help_menu_for_nsapp`, and a plain `with_items` submenu would skip
/// both silently -- no error, just a Window menu AppKit no longer manages.
#[cfg(target_os = "macos")]
pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let pkg_info = app.package_info();
let config = app.config();
let about_metadata = AboutMetadata {
name: Some(pkg_info.name.clone()),
version: Some(pkg_info.version.to_string()),
copyright: config.bundle.copyright.clone(),
authors: config.bundle.publisher.clone().map(|p| vec![p]),
..Default::default()
};
Menu::with_items(
app,
&[
&Submenu::with_items(
app,
pkg_info.name.clone(),
true,
&[
&PredefinedMenuItem::about(app, None, Some(about_metadata))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::services(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?,
// `Menu::default()`'s File submenu holds exactly one item on macOS
// -- close_window -- so dropping that item drops the submenu too.
&Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?,
&Submenu::with_items(
app,
"View",
true,
&[&PredefinedMenuItem::fullscreen(app, None)?],
)?,
&Submenu::with_id_and_items(
app,
WINDOW_SUBMENU_ID,
"Window",
true,
&[
&PredefinedMenuItem::minimize(app, None)?,
&PredefinedMenuItem::maximize(app, None)?,
],
)?,
// Empty upstream too on macOS: About lives in the app submenu.
&Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?,
],
)
}
+2 -1
View File
@@ -1,4 +1,5 @@
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
mod app_menu;
mod app_state;
mod archive;
mod builderlab;
@@ -342,7 +343,7 @@ pub fn run() {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
let app = builder
let app = app_menu::install(builder)
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
tauri::async_runtime::spawn(async move {
@@ -26,6 +26,18 @@ before(async () => {
window: dom.window,
IS_REACT_ACT_ENVIRONMENT: true,
});
// `navigator` is a getter-only global on Node, and the chord layer branches
// on `isMacPlatform()`. Pin it so these assertions describe macOS behaviour
// on every host instead of quietly inverting on a Linux runner.
Object.defineProperty(dom.window.navigator, "platform", {
configurable: true,
value: "MacIntel",
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
writable: true,
});
dom.window.matchMedia = () => ({
get matches() {
return reducedMotion;
@@ -379,3 +391,139 @@ test("reduced motion keeps the terminal cursor solid", async () => {
window.setInterval = originalSetInterval;
}
});
function tabFixture(overrides = {}) {
const calls = { close: [], input: [], select: [], spawn: 0 };
const subject = fixture({
onCloseSession(id) {
calls.close.push(id);
},
onInput(value) {
calls.input.push(value);
},
onNewSession() {
calls.spawn += 1;
},
onSelectSession(id) {
calls.select.push(id);
},
sessions: [
{ active: true, closing: false, id: "one", title: "SHELL" },
{ active: false, closing: false, id: "two", title: "LOG" },
],
...overrides,
});
// Spread order matters: `fixture` returns its own `calls`, so ours goes last.
return { ...subject, calls };
}
function press(init) {
// Dispatched at the window, which is where the substrate's capture-phase
// listener lives -- firing at the textarea would test React's synthetic
// tree instead of the layer actually under test.
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
...init,
});
act(() => {
window.dispatchEvent(event);
});
return event;
}
async function revealed(overrides) {
const subject = tabFixture(overrides);
await ready(subject.view);
await reveal(subject.view);
return subject;
}
test("tab chords drive new, close, and select while the terminal owns input", async () => {
const subject = await revealed({
sessions: [
{ active: false, closing: false, id: "one", title: "SHELL" },
{ active: true, closing: false, id: "two", title: "LOG" },
{ active: false, closing: false, id: "three", title: "TAIL" },
],
});
const spawn = press({ code: "KeyT", metaKey: true });
assert.equal(subject.calls.spawn, 1);
assert.equal(spawn.defaultPrevented, true, "⌘T must not reach the page");
const close = press({ code: "KeyW", metaKey: true });
assert.deepEqual(subject.calls.close, ["two"]);
assert.equal(close.defaultPrevented, true, "⌘W must not reach the page");
press({ code: "ArrowRight", metaKey: true, shiftKey: true });
press({ code: "ArrowLeft", metaKey: true, shiftKey: true });
// Three sessions, active in the middle: with only two tabs, prev and next
// resolve to the same id and a direction swap would pass unnoticed.
assert.deepEqual(subject.calls.select, ["three", "one"]);
// Nothing was mistaken for terminal input along the way.
assert.deepEqual(subject.calls.input, []);
});
test("tab chords stay inert while Buzz owns input", async () => {
const subject = tabFixture();
await ready(subject.view);
// Deliberately not revealed: owner is "buzz".
const spawn = press({ code: "KeyT", metaKey: true });
press({ code: "KeyW", metaKey: true });
press({ code: "ArrowRight", metaKey: true, shiftKey: true });
assert.equal(subject.calls.spawn, 0);
assert.deepEqual(subject.calls.close, []);
assert.deepEqual(subject.calls.select, []);
assert.equal(
spawn.defaultPrevented,
false,
"Buzz-mode ⌘T belongs to the rest of the app",
);
});
test("control chords keep reaching the PTY instead of the tab layer", async () => {
const subject = await revealed();
const werase = press({ code: "KeyW", ctrlKey: true });
assert.deepEqual(subject.calls.close, [], "^W is werase, not close-tab");
assert.equal(subject.calls.spawn, 0);
assert.equal(
werase.defaultPrevented,
false,
"the chord layer must not consume ^W before the textarea encodes it",
);
});
test("⌘W on an already-closing tab does not re-fire close", async () => {
const subject = await revealed({
sessions: [
{ active: true, closing: true, id: "one", title: "SHELL" },
{ active: false, closing: false, id: "two", title: "LOG" },
],
});
const close = press({ code: "KeyW", metaKey: true });
assert.deepEqual(subject.calls.close, []);
assert.equal(
close.defaultPrevented,
true,
"the chord is still ours -- swallowed, not forwarded",
);
});
test("the handoff chord still toggles with the tab layer installed", async () => {
const subject = tabFixture();
await ready(subject.view);
const substrate = subject.view.container.querySelector(
".buzz-terminal-substrate",
);
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
toggleChord();
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
});
@@ -2,13 +2,16 @@ import * as React from "react";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { cn } from "@/shared/lib/cn";
import { isMacPlatform } from "@/shared/lib/platform";
import { FadeController } from "./fadeController";
import {
INITIAL_HANDOFF_STATE,
accumulateScrollLines,
encodePaste,
encodeTerminalKey,
matchTabChord,
reduceHandoff,
stepSession,
} from "./terminalState";
import { buildTerminalBanner } from "./terminalBanner";
import { paintTerminalBanner } from "./terminalBannerPainter";
@@ -182,6 +185,30 @@ export function TerminalSubstrate({
const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => {
onFrameConsumed?.(nextFrame);
});
/**
* Tab chords are handled at the window in capture phase, like the ⌘J
* handoff, so they win over the focused textarea. Gated on terminal
* ownership: in Buzz mode these keys belong to the rest of the app.
*/
const runTabChord = React.useEffectEvent((event: KeyboardEvent): boolean => {
if (owner !== "terminal" || event.isComposing) return false;
const chord = matchTabChord(event, isMacPlatform());
if (!chord) return false;
if (chord === "new") {
onNewSession();
return true;
}
if (chord === "close") {
// A tab already closing has a disabled × in the tab bar; re-firing close
// on it would send a second shutdown for a session on its way out.
if (!activeSession || activeSession.closing) return true;
onCloseSession(activeSession.id);
return true;
}
const next = stepSession(sessions, chord === "next" ? 1 : -1);
if (next) onSelectSession(next);
return true;
});
const reportViewportSize = React.useEffectEvent(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -259,7 +286,13 @@ export function TerminalSubstrate({
if (!appSurface) return;
fadeRef.current = new FadeController(appSurface);
const handleKeyDown = (event: KeyboardEvent) => {
if (!enabled || !isToggleChord(event)) return;
if (!enabled) return;
if (runTabChord(event)) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
if (!isToggleChord(event)) return;
if (event.isComposing) {
handoffRef.current = reduceHandoff(handoffRef.current, {
type: "focus-lost",
@@ -6,7 +6,9 @@ import {
accumulateScrollLines,
encodePaste,
encodeTerminalKey,
matchTabChord,
reduceHandoff,
stepSession,
} from "./terminalState.ts";
test("ownership changes on completed chord, never key-down or repeat", () => {
@@ -95,3 +97,119 @@ test("pixel scrolling retains fractional lines in both directions", () => {
assert.equal(result.lines, -1);
assert.equal(result.state.remainderPx, -1);
});
function chord(overrides) {
return {
altKey: false,
code: "KeyT",
ctrlKey: false,
metaKey: false,
shiftKey: false,
...overrides,
};
}
test("tab chords match the platform's primary modifier only", () => {
assert.equal(matchTabChord(chord({ metaKey: true }), true), "new");
assert.equal(
matchTabChord(chord({ code: "KeyW", metaKey: true }), true),
"close",
);
assert.equal(
matchTabChord(
chord({ code: "ArrowLeft", metaKey: true, shiftKey: true }),
true,
),
"previous",
);
assert.equal(
matchTabChord(
chord({ code: "ArrowRight", metaKey: true, shiftKey: true }),
true,
),
"next",
);
// The whole reason the matcher takes `isMac`: on macOS ^W is werase and ^T
// transposes. Claiming them here would swallow them before the textarea
// could encode them for the PTY.
assert.equal(matchTabChord(chord({ ctrlKey: true }), true), null);
assert.equal(
matchTabChord(chord({ code: "KeyW", ctrlKey: true }), true),
null,
);
assert.equal(
matchTabChord(chord({ ctrlKey: true, metaKey: true }), true),
null,
"Control must veto even alongside Command",
);
// Non-mac mirrors it: Ctrl is primary, Meta is not.
assert.equal(matchTabChord(chord({ ctrlKey: true }), false), "new");
assert.equal(matchTabChord(chord({ metaKey: true }), false), null);
// Unmodified and Alt-modified keys stay with the terminal.
assert.equal(matchTabChord(chord({}), true), null);
assert.equal(
matchTabChord(chord({ altKey: true, metaKey: true }), true),
null,
);
// Arrows without Shift are cursor keys, not tab switches.
assert.equal(
matchTabChord(chord({ code: "ArrowLeft", metaKey: true }), true),
null,
);
// Shift+Cmd+T/W are not chords we claim.
assert.equal(
matchTabChord(chord({ metaKey: true, shiftKey: true }), true),
null,
);
});
test("tab stepping wraps and skips tabs whose select button is disabled", () => {
const three = [
{ active: false, closing: false, id: "a" },
{ active: true, closing: false, id: "b" },
{ active: false, closing: false, id: "c" },
];
assert.equal(stepSession(three, 1), "c");
assert.equal(stepSession(three, -1), "a");
// Wrap at both ends.
const first = [
{ active: true, closing: false, id: "a" },
{ active: false, closing: false, id: "b" },
];
assert.equal(stepSession(first, -1), "b");
assert.equal(stepSession(first, 1), "b");
// A closing tab is disabled in the tab bar, so the keyboard skips past it.
assert.equal(
stepSession(
[
{ active: true, closing: false, id: "a" },
{ active: false, closing: true, id: "b" },
{ active: false, closing: false, id: "c" },
],
1,
),
"c",
);
// Nowhere to go: single tab, and all-others-closing.
assert.equal(
stepSession([{ active: true, closing: false, id: "a" }], 1),
null,
);
assert.equal(
stepSession(
[
{ active: true, closing: false, id: "a" },
{ active: false, closing: true, id: "b" },
],
1,
),
null,
);
assert.equal(stepSession([], 1), null);
});
@@ -93,6 +93,72 @@ export function encodeTerminalKey(event: {
return null;
}
export type TabChord = "close" | "new" | "next" | "previous";
/**
* Tab-management chords, matched on `code` so they survive alternate layouts.
* `isMac` is a parameter rather than a `navigator` read so matching stays pure,
* the same shape as `matchBackForwardChord`.
*
* The modifier is the platform's primary one and *only* that one — deliberately
* narrower than the sibling `isToggleChord`, which accepts either. On macOS
* `^W` is werase and `^T` is transpose; a capture-phase listener that claimed
* them would consume the event before the textarea could encode it, so Control
* must keep falling through to the PTY. Non-mac platforms pay the mirror cost
* on Ctrl (matching gnome-terminal, which moves its own tab chords onto
* Ctrl+Shift for exactly this reason) — see the report for why that is scoped
* out rather than solved here.
*
* ⌘W is matched even though macOS currently resolves it as the File > Close
* Window key equivalent before the webview sees any key event: the accelerator
* has to be released natively for this arm to ever run, and matching it now
* means the frontend needs no further change when it is.
*/
export function matchTabChord(
event: {
altKey: boolean;
code: string;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
},
isMac: boolean,
): TabChord | null {
if (event.altKey) return null;
if (isMac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey)
return null;
if (event.shiftKey) {
if (event.code === "ArrowLeft") return "previous";
if (event.code === "ArrowRight") return "next";
return null;
}
if (event.code === "KeyT") return "new";
if (event.code === "KeyW") return "close";
return null;
}
/**
* The id ⇧⌘←/→ moves to, wrapping at both ends.
*
* Closing tabs are skipped because the tab bar disables their select button —
* the keyboard path must not reach a tab the mouse path cannot.
*/
export function stepSession(
sessions: readonly { active: boolean; closing: boolean; id: string }[],
direction: -1 | 1,
): string | null {
const count = sessions.length;
const activeIndex = sessions.findIndex((session) => session.active);
if (activeIndex < 0) return null;
for (let offset = 1; offset < count; offset += 1) {
const index =
(((activeIndex + direction * offset) % count) + count) % count;
const candidate = sessions[index];
if (!candidate.closing) return candidate.id;
}
return null;
}
export function encodePaste(text: string, bracketed: boolean): string {
return bracketed ? `\u001b[200~${text}\u001b[201~` : text;
}