From ba02b11f137bd5917e6008dfe0024403d36a4699 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 18:32:57 +0100 Subject: [PATCH] feat(maestro): serve the configured leader to the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`, a module constant. Rebinding one left the other on `ctrl-a`, so the two escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`) moved on one client and not the other. `GET /api/info` now carries `leader`. The web binds that when it arrives and rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command bar render the configured chord; an absent or empty field — an older daemon, or an unreachable `/api/info` — leaves it on `ctrl-a`. Chose the existing `[tui].leader` served as-is over a new shared key: one knob, no precedence rules between two spellings, and no migration for operators who already set it. The name is a wart — it is the leader for every client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md` say so. --- crates/maestro-config/src/lib.rs | 14 ++++ crates/maestro-daemon/src/cli.rs | 1 + crates/maestro-daemon/src/config.rs | 29 +++++++++ crates/maestro-daemon/src/local_api.rs | 7 ++ crates/maestro-daemon/tests/attach.rs | 1 + crates/maestro-daemon/tests/branches.rs | 1 + crates/maestro-daemon/tests/local_api.rs | 41 ++++++++++++ crates/maestro-daemon/tests/web_ui.rs | 1 + crates/maestro-protocol/src/lib.rs | 6 ++ crates/maestro-tui/src/config.rs | 2 +- crates/maestro-tui/src/daemon_link.rs | 1 + docs/COMMAND-BAR.md | 6 ++ docs/INSTALL.md | 3 +- web/src/commands.test.ts | 32 +++++++++ web/src/commands.ts | 40 +++++++++--- web/src/help.test.ts | 29 ++++++++- web/src/main.ts | 82 ++++++++++++++++-------- web/src/types.ts | 4 ++ 18 files changed, 260 insertions(+), 40 deletions(-) diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 9a152dd..c4efec0 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml"; /// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`. pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; +/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical +/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey` +/// both produce. +pub const DEFAULT_LEADER: &str = "ctrl-a"; + #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("io: {0}")] @@ -152,6 +157,15 @@ pub struct TuiSection { pub keymap_insert: BTreeMap, } +impl TuiSection { + /// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for + /// history, but read by every client: the daemon serves it on + /// `GET /api/info` so the web follows the same knob (#159). + pub fn leader(&self) -> &str { + self.leader.as_deref().unwrap_or(DEFAULT_LEADER) + } +} + /// Palette presets supported by the TUI's `[tui].theme` setting. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TuiTheme { diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 3ac374d..c61a5e9 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -105,6 +105,7 @@ async fn run(config_override: Option) -> Result<(), Box> { config.home_dir.clone(), config.spawn_presets.clone(), projects, + config.leader.clone(), )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 7f32d26..ef70e73 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -92,6 +92,11 @@ pub struct DaemonConfig { /// start when none is present (#106): `[hooks].auto_install`, default /// `true`. pub hooks_auto_install: bool, + /// Canonical leader key advertised to clients in `GET /api/info` (#159): + /// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon + /// never binds it itself; it serves it so the web client and the TUI read + /// one knob. + pub leader: String, } impl DaemonConfig { @@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result String { mod tests { use super::*; + /// #159: the leader the operator set for the TUI is what the daemon + /// advertises, so rebinding it once moves both clients' chord. + #[test] + fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + std::fs::write(&path, "").unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.leader, "ctrl-a"); + + std::fs::write( + &path, + r#" +[tui] +leader = "ctrl-b" +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.leader, "ctrl-b"); + } + #[test] fn hooks_auto_install_defaults_true_and_is_opt_out() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 11861a4..173e932 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -39,6 +39,10 @@ pub struct LocalApiState { pub presets: Arc>, /// Project discovery (#54) backing `GET /api/projects`. pub projects: Arc, + /// Configured leader key (`[tui].leader`) advertised in `GET /api/info` + /// (#159), so the web client binds the chord the operator configured + /// rather than its own default. + pub leader: Arc, } pub fn router(state: LocalApiState) -> Router { @@ -219,6 +223,7 @@ async fn daemon_info(State(s): State) -> Json { presets: (*s.presets).clone(), build: crate::release::build_info(), instance: crate::release::instance_id().to_string(), + leader: (*s.leader).clone(), }) } @@ -374,6 +379,7 @@ pub fn state_for( home_dir: impl Into, presets: Vec, projects: Arc, + leader: impl Into, ) -> LocalApiState { LocalApiState { manager, @@ -381,6 +387,7 @@ pub fn state_for( home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), projects, + leader: Arc::new(leader.into()), } } diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 7a1711e..1cd9f53 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness { "/home/test", Vec::new(), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + maestro_config::DEFAULT_LEADER, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/branches.rs b/crates/maestro-daemon/tests/branches.rs index 1884e38..4b2d05c 100644 --- a/crates/maestro-daemon/tests/branches.rs +++ b/crates/maestro-daemon/tests/branches.rs @@ -27,6 +27,7 @@ async fn spawn() -> Harness { "/home/test", presets, Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + maestro_config::DEFAULT_LEADER, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 9861796..a1eeed3 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness { async fn spawn_with( hostname: &str, projects: std::sync::Arc, +) -> Harness { + spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await +} + +async fn spawn_with_leader( + hostname: &str, + projects: std::sync::Arc, + leader: &str, ) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); @@ -48,6 +56,7 @@ async fn spawn_with( "/home/test", presets, projects, + leader, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() { assert!(resp["version"].is_string()); } +/// #159: the web client reads its leader off `/api/info` instead of a +/// hardcoded constant, so the payload has to carry the configured one — not +/// the default dressed up as one. +#[tokio::test] +async fn info_advertises_the_configured_leader() { + let h = spawn_with_leader( + "naps-laptop", + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + "ctrl-b", + ) + .await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(resp["leader"], "ctrl-b"); +} + +#[tokio::test] +async fn info_reports_the_default_leader_when_unconfigured() { + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(resp["leader"], "ctrl-a"); +} + #[tokio::test] async fn info_reports_build_and_instance() { // The status indicator needs a release to name and a process to compare diff --git a/crates/maestro-daemon/tests/web_ui.rs b/crates/maestro-daemon/tests/web_ui.rs index dc6b8e0..82d0296 100644 --- a/crates/maestro-daemon/tests/web_ui.rs +++ b/crates/maestro-daemon/tests/web_ui.rs @@ -26,6 +26,7 @@ async fn spawn() -> Harness { "/home/test", maestro_config::SpawnSection::default().resolve().unwrap(), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + maestro_config::DEFAULT_LEADER, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 68fbeab..465a3b2 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -554,6 +554,12 @@ pub struct DaemonInfo { /// network blip, even when the build did not change. #[serde(default)] pub instance: String, + /// Canonical leader key the operator configured (`[tui].leader`, default + /// `ctrl-a`), served so the web client binds the same chord the TUI does + /// instead of its own hardcoded constant (#159). Empty from a daemon too + /// old to serve it, which clients read as "use your default". + #[serde(default)] + pub leader: String, } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 5df8f50..5fd6687 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -114,7 +114,7 @@ impl TuiConfig { TuiTheme::Nord => Palette::NORD, TuiTheme::Dracula => Palette::DRACULA, }; - let leader_str = section.leader.as_deref().unwrap_or("ctrl-a"); + let leader_str = section.leader(); let leader = InputKey::parse(leader_str) .map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?; let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000)); diff --git a/crates/maestro-tui/src/daemon_link.rs b/crates/maestro-tui/src/daemon_link.rs index ab18418..4862ff0 100644 --- a/crates/maestro-tui/src/daemon_link.rs +++ b/crates/maestro-tui/src/daemon_link.rs @@ -250,6 +250,7 @@ mod tests { dirty: false, }, instance: instance.into(), + leader: maestro_config::DEFAULT_LEADER.into(), } } diff --git a/docs/COMMAND-BAR.md b/docs/COMMAND-BAR.md index 2934ea5..1a38c9d 100644 --- a/docs/COMMAND-BAR.md +++ b/docs/COMMAND-BAR.md @@ -45,6 +45,12 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web `preventDefault`s it, since selecting all of a terminal pane does nothing useful. +One knob drives both clients: the daemon serves `[tui].leader` as `leader` on +`GET /api/info`, and the web binds that instead of its own constant (#159). A +daemon too old to send the field, or an unreachable `/api/info`, leaves the web +on `ctrl-a`. The section is named `[tui]` for history — it is the leader for +every client, not the TUI's alone. + The leader itself needs the same kind of way back: holding it consumes every press, on both clients, even before anything is bound behind it. Pressing it twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 2b91a7e..803cae4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change: ```toml [tui] theme = "dark" # see the theme list below -leader = "ctrl-a" # one byte that escapes Insert mode +leader = "ctrl-a" # one byte that escapes Insert mode; the web reads + # the same setting off /api/info chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` [tui.keymap.normal] diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index 63dc0da..93ce959 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { COMMAND_SPECS, CommandSet, + commandSpecs, DEFAULT_LEADER, canonicalizeKey, controlByte, @@ -165,6 +166,37 @@ describe("the leader", () => { expect(DEFAULT_LEADER).toBe("ctrl-a"); }); + // #159: the leader is the operator's, served on `/api/info`. Every chord + // behind it is built from it, so a rebound leader carries its escape hatches + // with it instead of leaving them on a stale ctrl-a. + it("builds the chords behind it from the configured leader", () => { + const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); + expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); + expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); + // Commands that do not hang off the leader are untouched. + expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); + }); + + it("dispatches a configured leader's chord and not the default's", () => { + const literalCtrlK = vi.fn(); + const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), { + leader: "ctrl-b", + }); + set.handle(key("b", { ctrlKey: true })); + expect(set.handle(key("k", { ctrlKey: true }))).toBe(true); + expect(literalCtrlK).toHaveBeenCalledTimes(1); + + // The old leader is an ordinary key again: it holds nothing, and the chord + // that used to follow it runs nothing. + set.handle(key("a", { ctrlKey: true })); + set.handle(key("k", { ctrlKey: true })); + expect(literalCtrlK).toHaveBeenCalledTimes(1); + }); + + it("sends the configured leader's own byte for the doubled chord", () => { + expect(controlByte("ctrl-b")).toBe("\x02"); + }); + it("holds the leader as a partial chord instead of selecting the page", () => { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { leader: DEFAULT_LEADER, diff --git a/web/src/commands.ts b/web/src/commands.ts index 2c8e12b..a692072 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -32,13 +32,26 @@ export interface Command extends CommandSpec { run: () => void; } -/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all - * in a browser, so a set that owns the leader `preventDefault`s it. */ +/** Leader used when the daemon does not name one — an older daemon, or an + * unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a` + * is select-all in a browser, so a set that owns the leader + * `preventDefault`s it. */ export const DEFAULT_LEADER = "ctrl-a"; -/** Every command the web client has. The single list this issue exists to - * create: labels, chords and grouping live here and nowhere else. */ -export const COMMAND_SPECS: readonly CommandSpec[] = [ +/** Every command the web client has, for one leader. The single list: labels, + * chords and grouping live here and nowhere else. Chords behind the leader are + * built from it (#159), so the configured leader — served on `/api/info` as + * `leader` — is what the overlay renders and the sets dispatch. */ +export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] { + return SPECS.map(({ leaderKeys, ...spec }) => + leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec, + ); +} + +/** A spec whose chords are fixed, or a spec that builds them from the leader. */ +type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] }; + +const SPECS: readonly SpecSource[] = [ { id: "spawn.open", label: "Open the spawn form", @@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ label: "Send a literal Ctrl-K to the pane", group: "Pane", keys: [`${DEFAULT_LEADER} ctrl-k`], + leaderKeys: (leader) => [`${leader} ctrl-k`], scope: "global", }, { @@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ label: "Send a literal leader key to the pane", group: "Pane", keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], + leaderKeys: (leader) => [`${leader} ${leader}`], scope: "global", }, { @@ -160,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ }, ]; -const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); +/** The command list under the default leader. Iteration order everywhere else + * (the help overlay, the bar) comes from here. */ +export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs(); /** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a - * test failure rather than a key that silently does nothing. */ -export function defineCommands(handlers: Record void>): Command[] { + * test failure rather than a key that silently does nothing. `leader` picks + * the chords behind the leader; omit it for a set that has none. */ +export function defineCommands( + handlers: Record void>, + leader: string = DEFAULT_LEADER, +): Command[] { + const byId = new Map(commandSpecs(leader).map((s) => [s.id, s])); return Object.entries(handlers).map(([id, run]) => { - const spec = SPEC_BY_ID.get(id); + const spec = byId.get(id); if (!spec) throw new Error(`unknown command id: ${id}`); return { ...spec, run }; }); diff --git a/web/src/help.test.ts b/web/src/help.test.ts index f5b9e70..3ce1c2a 100644 --- a/web/src/help.test.ts +++ b/web/src/help.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { keyCaps, publishedCommands } from "./commands.js"; +import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js"; import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; beforeEach(() => { @@ -44,6 +44,33 @@ describe("help overlay", () => { .toEqual(publishedCommands().map((c) => c.id)); }); + // #159: the leader comes from the daemon, so the overlay must render the + // configured chord — a hardcoded `Ctrl A` is the visible half of the bug. + it("renders the configured leader's chord, not a hardcoded Ctrl A", () => { + const bound = defineCommands( + { "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }, + "ctrl-b", + ); + publishCommands(bound); + try { + openHelp(); + const keysOf = (id: string) => + [ + ...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement) + .querySelectorAll("kbd"), + ] + .map((k) => k.textContent) + .join(" "); + expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K"); + expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B"); + } finally { + // Leave the registry as the rest of the suite expects it. + publishCommands( + defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }), + ); + } + }); + it("opens on ? and closes on a second ?", () => { press("?", { shiftKey: true }); expect(isHelpOpen()).toBe(true); diff --git a/web/src/main.ts b/web/src/main.ts index dafd9dd..2154af1 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -87,9 +87,15 @@ const store = new CatalogStore(); // Absolute paths are a fine fallback until it lands. let homeDir: string | undefined; void getDaemonInfo() - .then((info) => (homeDir = info.home_dir)) + .then((info) => { + homeDir = info.home_dir; + // The leader is the operator's, not this module's (#159): a daemon that + // names one rebinds the chords hanging off it, and the `?` overlay and the + // command bar re-render from the rebuilt set. + if (info.leader) useLeader(info.leader); + }) .catch(() => { - /* absolute paths are a fine fallback */ + /* absolute paths and the default leader are fine fallbacks */ }); // The daemon's identity and liveness: names the release in the sidebar's status @@ -439,32 +445,52 @@ const spawnKeys = new CommandSet( // terminal owns every plain keystroke, so nothing unmodified could ever get // the focus back out of it. // -// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the -// key as a partial chord and takes select-all off it, so the escape hatch -// docs/COMMAND-BAR.md describes has somewhere to land (#149). -const focusKeys = new CommandSet( - defineCommands({ - "focus.toggle": () => { - if (sidebar.hasListFocus()) { - pool.focusTerminal(); - } else { - drawer.open(); // no-op on desktop; on mobile the list is off-canvas - sidebar.focusList(); - } - }, - // The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line - // away from every pane, and leader ctrl-k gives it back as the literal - // 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the - // bar's, so the chord never opens the bar. - "pane.literalCtrlK": () => pool.sendToFocused("\x0b"), - // The leader itself, pressed twice, tmux-style: sends one literal leader - // byte and clears the pending chord instead of resolving as a dead chord - // (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a - // hardcoded byte, so a different leader would send its own. - "pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)), - }), - { leader: DEFAULT_LEADER }, -); +// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served +// on `/api/info`): it holds the key as a partial chord and takes select-all off +// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land +// (#149). +let currentLeader = DEFAULT_LEADER; + +function buildFocusKeys(leader: string): CommandSet { + return new CommandSet( + defineCommands( + { + "focus.toggle": () => { + if (sidebar.hasListFocus()) { + pool.focusTerminal(); + } else { + drawer.open(); // no-op on desktop; on mobile the list is off-canvas + sidebar.focusList(); + } + }, + // The escape hatch: binding ctrl-k takes readline's + // kill-to-end-of-line away from every pane, and leader ctrl-k gives it + // back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in + // this set, ahead of the bar's, so the chord never opens the bar. + "pane.literalCtrlK": () => pool.sendToFocused("\x0b"), + // The leader itself, pressed twice, tmux-style: sends one literal + // leader byte and clears the pending chord instead of resolving as a + // dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a + // hardcoded byte, so a rebound leader sends its own. + "pane.literalLeader": () => pool.sendToFocused(controlByte(leader)), + }, + leader, + ), + { leader }, + ); +} + +let focusKeys = buildFocusKeys(DEFAULT_LEADER); + +/** Rebind the leader-owning set to the daemon's leader. Re-registering the + * same command ids replaces them, so the help overlay and the command bar — + * both of which render the published registry when they open — show the + * configured chord rather than the default. */ +function useLeader(leader: string): void { + if (leader === currentLeader) return; + currentLeader = leader; + focusKeys = buildFocusKeys(leader); +} // Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command // like any other, so the `?` overlay lists it without a second list. diff --git a/web/src/types.ts b/web/src/types.ts index b18c86f..091883e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -98,6 +98,10 @@ export interface DaemonInfo { * value across a reconnect means the daemon restarted. Absent on older * daemons, which the restart flow treats as "cannot tell". */ instance?: string; + /** Canonical leader key the operator configured (`[tui].leader`, default + * `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or + * empty on an older daemon; the client then keeps its own default. */ + leader?: string; } /** Build provenance of the running daemon: what the status indicator names in