feat(maestro): serve the configured leader to the web client

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.
This commit is contained in:
Miguel Palhas
2026-09-03 18:33:01 +01:00
parent 59ede15d2b
commit ba02b11f13
18 changed files with 260 additions and 40 deletions
+14
View File
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`. /// 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"; 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)] #[derive(Debug, thiserror::Error)]
pub enum ConfigError { pub enum ConfigError {
#[error("io: {0}")] #[error("io: {0}")]
@@ -152,6 +157,15 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>, pub keymap_insert: BTreeMap<String, String>,
} }
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. /// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme { pub enum TuiTheme {
+1
View File
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(), config.home_dir.clone(),
config.spawn_presets.clone(), config.spawn_presets.clone(),
projects, projects,
config.leader.clone(),
)); ));
let listener = TcpListener::bind(config.bind_addr).await?; let listener = TcpListener::bind(config.bind_addr).await?;
+29
View File
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
/// start when none is present (#106): `[hooks].auto_install`, default /// start when none is present (#106): `[hooks].auto_install`, default
/// `true`. /// `true`.
pub hooks_auto_install: bool, 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 { impl DaemonConfig {
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
project_roots, project_roots,
project_scan_depth, project_scan_depth,
hooks_auto_install: file.hooks.auto_install(), hooks_auto_install: file.hooks.auto_install(),
leader: file.tui.leader().to_string(),
}) })
} }
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
mod tests { mod tests {
use super::*; 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] #[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() { fn hooks_auto_install_defaults_true_and_is_opt_out() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+7
View File
@@ -39,6 +39,10 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>, pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`. /// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>, pub projects: Arc<crate::projects::ProjectScanner>,
/// 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<String>,
} }
pub fn router(state: LocalApiState) -> Router { pub fn router(state: LocalApiState) -> Router {
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
presets: (*s.presets).clone(), presets: (*s.presets).clone(),
build: crate::release::build_info(), build: crate::release::build_info(),
instance: crate::release::instance_id().to_string(), instance: crate::release::instance_id().to_string(),
leader: (*s.leader).clone(),
}) })
} }
@@ -374,6 +379,7 @@ pub fn state_for(
home_dir: impl Into<String>, home_dir: impl Into<String>,
presets: Vec<SpawnPreset>, presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>, projects: Arc<crate::projects::ProjectScanner>,
leader: impl Into<String>,
) -> LocalApiState { ) -> LocalApiState {
LocalApiState { LocalApiState {
manager, manager,
@@ -381,6 +387,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()), home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets), presets: Arc::new(presets),
projects, projects,
leader: Arc::new(leader.into()),
} }
} }
+1
View File
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test", "/home/test",
Vec::new(), Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+1
View File
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
presets, presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+41
View File
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
async fn spawn_with( async fn spawn_with(
hostname: &str, hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>, projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
}
async fn spawn_with_leader(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
leader: &str,
) -> Harness { ) -> Harness {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let manager = fx.manager.clone(); let manager = fx.manager.clone();
@@ -48,6 +56,7 @@ async fn spawn_with(
"/home/test", "/home/test",
presets, presets,
projects, projects,
leader,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
assert!(resp["version"].is_string()); 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] #[tokio::test]
async fn info_reports_build_and_instance() { async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare // The status indicator needs a release to name and a process to compare
+1
View File
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
maestro_config::SpawnSection::default().resolve().unwrap(), maestro_config::SpawnSection::default().resolve().unwrap(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+6
View File
@@ -554,6 +554,12 @@ pub struct DaemonInfo {
/// network blip, even when the build did not change. /// network blip, even when the build did not change.
#[serde(default)] #[serde(default)]
pub instance: String, 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 /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
+1 -1
View File
@@ -114,7 +114,7 @@ impl TuiConfig {
TuiTheme::Nord => Palette::NORD, TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA, 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) let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?; .map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000)); let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
+1
View File
@@ -250,6 +250,7 @@ mod tests {
dirty: false, dirty: false,
}, },
instance: instance.into(), instance: instance.into(),
leader: maestro_config::DEFAULT_LEADER.into(),
} }
} }
+6
View File
@@ -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 `preventDefault`s it, since selecting all of a terminal pane does nothing
useful. 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 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 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 twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
+2 -1
View File
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
```toml ```toml
[tui] [tui]
theme = "dark" # see the theme list below 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` chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal] [tui.keymap.normal]
+32
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
COMMAND_SPECS, COMMAND_SPECS,
CommandSet, CommandSet,
commandSpecs,
DEFAULT_LEADER, DEFAULT_LEADER,
canonicalizeKey, canonicalizeKey,
controlByte, controlByte,
@@ -165,6 +166,37 @@ describe("the leader", () => {
expect(DEFAULT_LEADER).toBe("ctrl-a"); 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", () => { it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER, leader: DEFAULT_LEADER,
+31 -9
View File
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
run: () => void; run: () => void;
} }
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all /** Leader used when the daemon does not name one — an older daemon, or an
* in a browser, so a set that owns the leader `preventDefault`s it. */ * 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"; export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has. The single list this issue exists to /** Every command the web client has, for one leader. The single list: labels,
* create: labels, chords and grouping live here and nowhere else. */ * chords and grouping live here and nowhere else. Chords behind the leader are
export const COMMAND_SPECS: readonly CommandSpec[] = [ * 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", id: "spawn.open",
label: "Open the spawn form", label: "Open the spawn form",
@@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal Ctrl-K to the pane", label: "Send a literal Ctrl-K to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`], keys: [`${DEFAULT_LEADER} ctrl-k`],
leaderKeys: (leader) => [`${leader} ctrl-k`],
scope: "global", scope: "global",
}, },
{ {
@@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal leader key to the pane", label: "Send a literal leader key to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
leaderKeys: (leader) => [`${leader} ${leader}`],
scope: "global", 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 /** 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. */ * test failure rather than a key that silently does nothing. `leader` picks
export function defineCommands(handlers: Record<string, () => void>): Command[] { * the chords behind the leader; omit it for a set that has none. */
export function defineCommands(
handlers: Record<string, () => void>,
leader: string = DEFAULT_LEADER,
): Command[] {
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
return Object.entries(handlers).map(([id, run]) => { 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}`); if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run }; return { ...spec, run };
}); });
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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"; import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => { beforeEach(() => {
@@ -44,6 +44,33 @@ describe("help overlay", () => {
.toEqual(publishedCommands().map((c) => c.id)); .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 ?", () => { it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true }); press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true); expect(isHelpOpen()).toBe(true);
+54 -28
View File
@@ -87,9 +87,15 @@ const store = new CatalogStore();
// Absolute paths are a fine fallback until it lands. // Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined; let homeDir: string | undefined;
void getDaemonInfo() 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(() => { .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 // 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 // terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it. // the focus back out of it.
// //
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// key as a partial chord and takes select-all off it, so the escape hatch // on `/api/info`): it holds the key as a partial chord and takes select-all off
// docs/COMMAND-BAR.md describes has somewhere to land (#149). // it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
const focusKeys = new CommandSet( // (#149).
defineCommands({ let currentLeader = DEFAULT_LEADER;
"focus.toggle": () => {
if (sidebar.hasListFocus()) { function buildFocusKeys(leader: string): CommandSet {
pool.focusTerminal(); return new CommandSet(
} else { defineCommands(
drawer.open(); // no-op on desktop; on mobile the list is off-canvas {
sidebar.focusList(); "focus.toggle": () => {
} if (sidebar.hasListFocus()) {
}, pool.focusTerminal();
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line } else {
// away from every pane, and leader ctrl-k gives it back as the literal drawer.open(); // no-op on desktop; on mobile the list is off-canvas
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the sidebar.focusList();
// 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 // The escape hatch: binding ctrl-k takes readline's
// byte and clears the pending chord instead of resolving as a dead chord // kill-to-end-of-line away from every pane, and leader ctrl-k gives it
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a // back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
// hardcoded byte, so a different leader would send its own. // this set, ahead of the bar's, so the chord never opens the bar.
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)), "pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
}), // The leader itself, pressed twice, tmux-style: sends one literal
{ leader: DEFAULT_LEADER }, // 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 // 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. // like any other, so the `?` overlay lists it without a second list.
+4
View File
@@ -98,6 +98,10 @@ export interface DaemonInfo {
* value across a reconnect means the daemon restarted. Absent on older * value across a reconnect means the daemon restarted. Absent on older
* daemons, which the restart flow treats as "cannot tell". */ * daemons, which the restart flow treats as "cannot tell". */
instance?: string; 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 /** Build provenance of the running daemon: what the status indicator names in