From b32d87df13693765fa319846b0be0e96ac2eee9d Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 22:43:12 -0700 Subject: [PATCH 1/6] chore: bump version to 0.33.57 --- Cargo.lock | 8 ++++---- agentmux-cef/Cargo.toml | 2 +- agentmux-launcher/Cargo.toml | 2 +- agentmux-srv/Cargo.toml | 2 +- agentmux-wsh/Cargo.toml | 2 +- package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed576c0bb..aaaa8c0ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agentmux-cef" -version = "0.33.56" +version = "0.33.57" dependencies = [ "axum 0.8.8", "cef", @@ -32,7 +32,7 @@ dependencies = [ [[package]] name = "agentmux-launcher" -version = "0.33.56" +version = "0.33.57" dependencies = [ "windows-sys 0.59.0", "winres", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "agentmux-srv" -version = "0.33.56" +version = "0.33.57" dependencies = [ "async-stream", "axum 0.7.9", @@ -77,7 +77,7 @@ dependencies = [ [[package]] name = "agentmux-wsh" -version = "0.33.56" +version = "0.33.57" dependencies = [ "base64", "clap", diff --git a/agentmux-cef/Cargo.toml b/agentmux-cef/Cargo.toml index 8f62b889c..ccbddd552 100644 --- a/agentmux-cef/Cargo.toml +++ b/agentmux-cef/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-cef" -version = "0.33.56" +version = "0.33.57" description = "AgentMux CEF Host — Pinned Chromium renderer replacing system WebView2" authors = ["AgentMux Corp"] edition = "2021" diff --git a/agentmux-launcher/Cargo.toml b/agentmux-launcher/Cargo.toml index c24efcf20..d28ffd38c 100644 --- a/agentmux-launcher/Cargo.toml +++ b/agentmux-launcher/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-launcher" -version = "0.33.56" +version = "0.33.57" description = "AgentMux Launcher — tiny exe that sets DLL path then spawns the CEF host" authors = ["AgentMux Corp"] edition = "2021" diff --git a/agentmux-srv/Cargo.toml b/agentmux-srv/Cargo.toml index 9fae83c89..6df7cfe94 100644 --- a/agentmux-srv/Cargo.toml +++ b/agentmux-srv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-srv" -version = "0.33.56" +version = "0.33.57" edition = "2021" description = "AgentMux Rust backend server" diff --git a/agentmux-wsh/Cargo.toml b/agentmux-wsh/Cargo.toml index 139b868f4..dec970cfd 100644 --- a/agentmux-wsh/Cargo.toml +++ b/agentmux-wsh/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-wsh" -version = "0.33.56" +version = "0.33.57" edition = "2021" description = "Shell integration CLI for AgentMux" diff --git a/package.json b/package.json index 24eced409..05e120504 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "productName": "AgentMux", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.33.56", + "version": "0.33.57", "homepage": "https://github.com/agentmuxai/agentmux", "build": { "appId": "ai.agentmux.app" From 6defaaeb579c3944eb3edefbb4985d24d4d2fe5f Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 22:43:26 -0700 Subject: [PATCH 2/6] chore: sync package-lock.json to 0.33.57 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e615d7dee..3bec44202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentmux", - "version": "0.33.56", + "version": "0.33.57", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentmux", - "version": "0.33.56", + "version": "0.33.57", "license": "Apache-2.0", "workspaces": [ "docs" From 449088201d3ec8431710e4bcdf4a6dca00eaf6c1 Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 22:43:44 -0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(palette):=20command=20palette=20?= =?UTF-8?q?=E2=80=94=20Ctrl+P=20UI=20+=20run=5Fcommand=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 commands across 5 categories (open, split, window, tab, pane, dev). Agents/MCP can invoke any command programmatically via the run_command IPC endpoint without opening the UI. - frontend/app/store/command-registry.ts: CommandRegistry singleton, registerDefaultCommands(), agentmux-run-command event listener - frontend/app/modals/command-palette.tsx: SolidJS overlay with fuzzy search, arrow key nav, Enter to execute, Escape to dismiss - frontend/app/modals/command-palette.scss: palette styles - frontend/app/store/keymodel.ts: Ctrl+P → opens palette - frontend/app/modals/modalregistry.tsx: registers CommandPaletteModal - frontend/wave.ts: calls registerDefaultCommands() at init - agentmux-cef/src/commands/palette.rs: run_command handler, dispatches agentmux-run-command CustomEvent to target browser window - agentmux-cef/src/commands/mod.rs: pub mod palette - agentmux-cef/src/ipc.rs: "run_command" route arm Co-Authored-By: Claude Sonnet 4.6 --- agentmux-cef/src/commands/mod.rs | 1 + agentmux-cef/src/commands/palette.rs | 50 ++++ agentmux-cef/src/ipc.rs | 3 + frontend/app/modals/command-palette.scss | 137 +++++++++ frontend/app/modals/command-palette.tsx | 155 ++++++++++ frontend/app/modals/modalregistry.tsx | 2 + frontend/app/store/command-registry.ts | 362 +++++++++++++++++++++++ frontend/app/store/keymodel.ts | 5 + frontend/wave.ts | 2 + 9 files changed, 717 insertions(+) create mode 100644 agentmux-cef/src/commands/palette.rs create mode 100644 frontend/app/modals/command-palette.scss create mode 100644 frontend/app/modals/command-palette.tsx create mode 100644 frontend/app/store/command-registry.ts diff --git a/agentmux-cef/src/commands/mod.rs b/agentmux-cef/src/commands/mod.rs index 8985f65a7..026cb1180 100644 --- a/agentmux-cef/src/commands/mod.rs +++ b/agentmux-cef/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod providers; pub mod drag; pub mod clipboard; pub mod stubs; +pub mod palette; use std::sync::Arc; use crate::state::AppState; diff --git a/agentmux-cef/src/commands/palette.rs b/agentmux-cef/src/commands/palette.rs new file mode 100644 index 000000000..aed07cb2a --- /dev/null +++ b/agentmux-cef/src/commands/palette.rs @@ -0,0 +1,50 @@ +// Copyright 2026, AgentMux Corp. +// SPDX-License-Identifier: Apache-2.0 +// +// `run_command` IPC handler — dispatches a command ID to the frontend registry. +// +// The Rust side does not own the command registry; it simply forwards the ID +// to the frontend via a CEF `CustomEvent`. The frontend's `commandRegistry.run(id)` +// handles validation and execution. + +use std::sync::Arc; + +use cef::{CefString, ImplBrowser, ImplFrame}; + +use crate::state::AppState; + +/// Dispatch a command palette ID to the frontend of the target window. +/// +/// Args: +/// `id` — stable command ID (e.g. `"open:terminal"`) +/// `windowLabel` — (optional) which window to target; defaults to `"main"` +pub fn run_command(state: &Arc, args: &serde_json::Value) -> Result { + let id = args["id"] + .as_str() + .ok_or_else(|| "run_command: missing 'id' field".to_string())?; + + let window_label = args["windowLabel"].as_str().unwrap_or("main"); + + let js = format!( + "window.dispatchEvent(new CustomEvent('agentmux-run-command', {{ detail: {{ id: {:?} }} }}));", + id + ); + + let browsers = state.browsers.lock(); + let browser = browsers + .get(window_label) + .or_else(|| browsers.values().next()); + + if let Some(browser) = browser { + if let Some(frame) = browser.main_frame() { + let code = CefString::from(js.as_str()); + let url = CefString::from(""); + frame.execute_java_script(Some(&code), Some(&url), 0); + tracing::debug!(id = %id, window = %window_label, "[palette] dispatched run_command"); + } + } else { + tracing::warn!(id = %id, "[palette] run_command: no browser available"); + } + + Ok(serde_json::Value::Null) +} diff --git a/agentmux-cef/src/ipc.rs b/agentmux-cef/src/ipc.rs index 65880ca53..7da29825c 100644 --- a/agentmux-cef/src/ipc.rs +++ b/agentmux-cef/src/ipc.rs @@ -270,6 +270,9 @@ async fn route_command( "open_in_editor" => commands::platform::open_in_editor(args), "copy_file_to_dir" => commands::providers::copy_file_to_dir(args), + // ---- Command palette ---- + "run_command" => commands::palette::run_command(state, args), + // ---- Unknown command ---- _ => Err(format!("Unknown command: {}", cmd)), } diff --git a/frontend/app/modals/command-palette.scss b/frontend/app/modals/command-palette.scss new file mode 100644 index 000000000..ff4e13782 --- /dev/null +++ b/frontend/app/modals/command-palette.scss @@ -0,0 +1,137 @@ +// Copyright 2026, AgentMux Corp. +// SPDX-License-Identifier: Apache-2.0 + +.command-palette-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 900; +} + +.command-palette-container { + position: fixed; + top: 20%; + left: 50%; + transform: translateX(-50%); + width: 560px; + max-height: 480px; + background: var(--modal-bg-color, #1e1e1e); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + z-index: 901; + display: flex; + flex-direction: column; + overflow: hidden; + + // Ensure command palette is above all other content + isolation: isolate; +} + +.command-palette-input-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color, #444); + flex-shrink: 0; +} + +.command-palette-search-icon { + color: var(--secondary-text-color, #888); + font-size: 13px; + flex-shrink: 0; +} + +.command-palette-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--main-text-color, #e0e0e0); + font-size: 14px; + font-family: var(--base-font, inherit); + line-height: 1.5; + + &::placeholder { + color: var(--secondary-text-color, #666); + } +} + +.command-palette-esc-hint { + font-size: 10px; + color: var(--secondary-text-color, #666); + background: var(--panel-bg-color, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 3px; + padding: 1px 5px; + flex-shrink: 0; + font-family: var(--base-font, inherit); +} + +.command-palette-list { + overflow-y: auto; + flex: 1; + padding: 4px 0; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border-color, #555); + border-radius: 3px; + } +} + +.command-palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + cursor: pointer; + user-select: none; + + &.selected { + background: var(--highlight-bg-color, #2d4a6e); + } + + &:hover:not(.selected) { + background: var(--hover-bg-color, #2a2a2a); + } +} + +.command-palette-item-icon { + font-size: 13px; + color: var(--secondary-text-color, #888); + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.command-palette-item-label { + flex: 1; + font-size: 13px; + color: var(--main-text-color, #e0e0e0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-palette-item-category { + font-size: 11px; + color: var(--secondary-text-color, #666); + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.command-palette-empty { + padding: 24px 14px; + text-align: center; + color: var(--secondary-text-color, #666); + font-size: 13px; + font-style: italic; +} diff --git a/frontend/app/modals/command-palette.tsx b/frontend/app/modals/command-palette.tsx new file mode 100644 index 000000000..0060dbea5 --- /dev/null +++ b/frontend/app/modals/command-palette.tsx @@ -0,0 +1,155 @@ +// Copyright 2026, AgentMux Corp. +// SPDX-License-Identifier: Apache-2.0 +// +// Command palette modal — opened via Ctrl+P, lists all registered commands. + +import { commandRegistry, type CommandEntry } from "@/app/store/command-registry"; +import { modalsModel } from "@/app/store/modalmodel"; +import { disableGlobalKeybindings, enableGlobalKeybindings } from "@/app/store/keymodel"; +import { createMemo, createSignal, For, onCleanup, onMount, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import "./command-palette.scss"; + +const CATEGORY_ORDER: string[] = ["Open", "Split", "Window", "Tab", "Pane", "Dev"]; + +function sortCommands(cmds: CommandEntry[]): CommandEntry[] { + return [...cmds].sort((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category); + const bi = CATEGORY_ORDER.indexOf(b.category); + const ac = ai === -1 ? 99 : ai; + const bc = bi === -1 ? 99 : bi; + if (ac !== bc) return ac - bc; + return a.label.localeCompare(b.label); + }); +} + +const CommandPaletteModal = (): JSX.Element => { + const [query, setQuery] = createSignal(""); + const [selectedIdx, setSelectedIdx] = createSignal(0); + let inputRef!: HTMLInputElement; + + const filtered = createMemo(() => { + const q = query().toLowerCase().trim(); + const all = sortCommands(commandRegistry.all()); + if (!q) return all; + return all.filter( + (cmd) => + cmd.label.toLowerCase().includes(q) || + cmd.id.toLowerCase().includes(q) || + cmd.category.toLowerCase().includes(q) + ); + }); + + // Clamp selectedIdx when results change + const clampedIdx = createMemo(() => { + const len = filtered().length; + if (len === 0) return 0; + return Math.min(selectedIdx(), len - 1); + }); + + onMount(() => { + disableGlobalKeybindings(); + inputRef?.focus(); + }); + + onCleanup(() => { + enableGlobalKeybindings(); + }); + + function close() { + modalsModel.popModal(); + } + + function executeSelected() { + const cmds = filtered(); + const idx = clampedIdx(); + if (cmds.length === 0) return; + const cmd = cmds[idx]; + close(); + // Execute after close so the palette doesn't interfere + setTimeout(() => { + void Promise.resolve(cmd.execute()); + }, 0); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, filtered().length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + executeSelected(); + return; + } + } + + function handleInput(e: InputEvent) { + setQuery((e.target as HTMLInputElement).value); + setSelectedIdx(0); + } + + return ( + +
+
+
+ + + ESC +
+
+ + {(cmd, i) => ( +
{ + setSelectedIdx(i()); + executeSelected(); + }} + onMouseEnter={() => setSelectedIdx(i())} + > + {cmd.icon && ( + + )} + {cmd.label} + {cmd.category} +
+ )} +
+ {filtered().length === 0 && ( +
No commands match "{query()}"
+ )} +
+
+ + ); +}; + +CommandPaletteModal.displayName = "CommandPaletteModal"; + +export { CommandPaletteModal }; diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index cb5066ce2..3b2e93a81 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -4,6 +4,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import type { JSX } from "solid-js"; import { AboutModal } from "./about"; +import { CommandPaletteModal } from "./command-palette"; import { UserInputModal } from "./userinputmodal"; // Onboarding modals removed for lightweight build @@ -11,6 +12,7 @@ const modalRegistry: { [key: string]: (props: any) => JSX.Element } = { [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, + [CommandPaletteModal.displayName || "CommandPaletteModal"]: CommandPaletteModal, }; export const getModalComponent = (key: string): ((props: any) => JSX.Element) | undefined => { diff --git a/frontend/app/store/command-registry.ts b/frontend/app/store/command-registry.ts new file mode 100644 index 000000000..b88ee0a64 --- /dev/null +++ b/frontend/app/store/command-registry.ts @@ -0,0 +1,362 @@ +// Copyright 2026, AgentMux Corp. +// SPDX-License-Identifier: Apache-2.0 +// +// Global command registry — the single source of truth for all palette commands. +// Both the Ctrl+P UI and the `run_command` IPC endpoint use this registry. + +import { + atoms, + createBlock, + createBlockSplitHorizontally, + createBlockSplitVertically, + createTab, + getApi, + setActiveTab, +} from "@/app/store/global"; +import { WorkspaceService } from "@/app/store/services"; +import { getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; +import { invokeCommand } from "@/app/platform/ipc"; +import { fireAndForget } from "@/util/util"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CommandEntry { + id: string; + label: string; + category: string; + icon?: string; + iconColor?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: () => void | Promise; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +class CommandRegistry { + private commands = new Map(); + + register(entry: CommandEntry): void { + this.commands.set(entry.id, entry); + } + + get(id: string): CommandEntry | undefined { + return this.commands.get(id); + } + + all(): CommandEntry[] { + return Array.from(this.commands.values()); + } + + run(id: string): boolean { + const cmd = this.commands.get(id); + if (!cmd) return false; + fireAndForget(async () => { + await cmd.execute(); + }); + return true; + } +} + +export const commandRegistry = new CommandRegistry(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getFocusedBlockIdForSplit(): string | null { + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = layoutModel.focusedNode?.(); + return focusedNode?.data?.blockId ?? null; +} + +function getDefaultSplitBlockDef() { + return { meta: { view: "term", controller: "shell" } }; +} + +function getAllTabs(ws: any): string[] { + return [...(ws.pinnedtabids ?? []), ...(ws.tabids ?? [])]; +} + +function switchTab(offset: number) { + const ws = atoms.workspace(); + const curTabId = atoms.activeTabId(); + const tabids = getAllTabs(ws); + const tabIdx = tabids.indexOf(curTabId); + if (tabIdx === -1) return; + const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length; + setActiveTab(tabids[newTabIdx]); +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerDefaultCommands(): void { + // ---- open ---- + commandRegistry.register({ + id: "open:terminal", + label: "Open Terminal", + category: "Open", + icon: "square-terminal", + execute: () => createBlock({ meta: { view: "term", controller: "shell" } }), + }); + commandRegistry.register({ + id: "open:agent", + label: "Open Agent", + category: "Open", + icon: "sparkles", + iconColor: "#cc785c", + execute: () => + createBlock({ meta: { view: "agent", controller: "cmd", cmd: "", "cmd:args": [], "cmd:interactive": true, "cmd:runonstart": false } }), + }); + commandRegistry.register({ + id: "open:forge", + label: "Open Forge", + category: "Open", + icon: "hammer", + iconColor: "#a78bfa", + execute: () => createBlock({ meta: { view: "forge" } }), + }); + commandRegistry.register({ + id: "open:sysinfo", + label: "Open System Info", + category: "Open", + icon: "chart-line", + execute: () => createBlock({ meta: { view: "sysinfo" } }), + }); + commandRegistry.register({ + id: "open:identity", + label: "Open Identity", + category: "Open", + icon: "id-card", + iconColor: "#a78bfa", + execute: () => createBlock({ meta: { view: "identity" } }), + }); + commandRegistry.register({ + id: "open:help", + label: "Open Help", + category: "Open", + icon: "circle-question", + execute: () => createBlock({ meta: { view: "help" } }), + }); + commandRegistry.register({ + id: "open:swarm", + label: "Open Swarm", + category: "Open", + icon: "bee", + iconColor: "#f59e0b", + execute: () => createBlock({ meta: { view: "swarm" } }), + }); + + // ---- split ---- + commandRegistry.register({ + id: "split:right", + label: "Split Right", + category: "Split", + icon: "table-columns", + execute: async () => { + const blockId = getFocusedBlockIdForSplit(); + if (blockId) await createBlockSplitHorizontally(getDefaultSplitBlockDef(), blockId, "after"); + }, + }); + commandRegistry.register({ + id: "split:left", + label: "Split Left", + category: "Split", + icon: "table-columns", + execute: async () => { + const blockId = getFocusedBlockIdForSplit(); + if (blockId) await createBlockSplitHorizontally(getDefaultSplitBlockDef(), blockId, "before"); + }, + }); + commandRegistry.register({ + id: "split:down", + label: "Split Down", + category: "Split", + icon: "table-rows", + execute: async () => { + const blockId = getFocusedBlockIdForSplit(); + if (blockId) await createBlockSplitVertically(getDefaultSplitBlockDef(), blockId, "after"); + }, + }); + commandRegistry.register({ + id: "split:up", + label: "Split Up", + category: "Split", + icon: "table-rows", + execute: async () => { + const blockId = getFocusedBlockIdForSplit(); + if (blockId) await createBlockSplitVertically(getDefaultSplitBlockDef(), blockId, "before"); + }, + }); + + // ---- window ---- + commandRegistry.register({ + id: "window:new", + label: "New Window", + category: "Window", + icon: "clone", + execute: () => getApi().openNewWindow().catch(console.error), + }); + commandRegistry.register({ + id: "window:close", + label: "Close Window", + category: "Window", + icon: "xmark", + execute: () => getApi().closeWindow().catch(console.error), + }); + commandRegistry.register({ + id: "window:minimize", + label: "Minimize Window", + category: "Window", + icon: "window-minimize", + execute: () => getApi().minimizeWindow(), + }); + commandRegistry.register({ + id: "window:maximize", + label: "Toggle Maximize", + category: "Window", + icon: "window-maximize", + execute: () => getApi().maximizeWindow(), + }); + + // ---- tab ---- + commandRegistry.register({ + id: "tab:new", + label: "New Tab", + category: "Tab", + icon: "plus", + execute: () => createTab(), + }); + commandRegistry.register({ + id: "tab:close", + label: "Close Tab", + category: "Tab", + icon: "xmark", + execute: () => { + const ws = atoms.workspace(); + if (!ws) return; + const tabId = atoms.activeTabId(); + WorkspaceService.CloseTab(ws.oid, tabId).catch(console.error); + }, + }); + commandRegistry.register({ + id: "tab:next", + label: "Next Tab", + category: "Tab", + execute: () => switchTab(1), + }); + commandRegistry.register({ + id: "tab:prev", + label: "Previous Tab", + category: "Tab", + execute: () => switchTab(-1), + }); + + // ---- pane ---- + commandRegistry.register({ + id: "pane:close", + label: "Close Pane", + category: "Pane", + icon: "xmark", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel)); + }, + }); + commandRegistry.register({ + id: "pane:magnify", + label: "Toggle Magnify", + category: "Pane", + icon: "up-right-and-down-left-from-center", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = layoutModel.focusedNode?.(); + if (focusedNode != null) { + layoutModel.magnifyNodeToggle(focusedNode.id); + } + }, + }); + commandRegistry.register({ + id: "pane:focus:right", + label: "Focus Pane Right", + category: "Pane", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + layoutModel.switchNodeFocusInDirection(NavigateDirection.Right); + }, + }); + commandRegistry.register({ + id: "pane:focus:left", + label: "Focus Pane Left", + category: "Pane", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + layoutModel.switchNodeFocusInDirection(NavigateDirection.Left); + }, + }); + commandRegistry.register({ + id: "pane:focus:up", + label: "Focus Pane Up", + category: "Pane", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + layoutModel.switchNodeFocusInDirection(NavigateDirection.Up); + }, + }); + commandRegistry.register({ + id: "pane:focus:down", + label: "Focus Pane Down", + category: "Pane", + execute: () => { + const layoutModel = getLayoutModelForStaticTab(); + layoutModel.switchNodeFocusInDirection(NavigateDirection.Down); + }, + }); + + // ---- dev ---- + commandRegistry.register({ + id: "dev:devtools", + label: "Toggle DevTools", + category: "Dev", + icon: "code", + execute: () => getApi().toggleDevtools(), + }); + commandRegistry.register({ + id: "dev:restart_backend", + label: "Restart Backend", + category: "Dev", + icon: "rotate", + execute: () => getApi().restartBackend().catch(console.error), + }); + commandRegistry.register({ + id: "dev:open_settings", + label: "Open Settings File", + category: "Dev", + icon: "cog", + execute: async () => { + try { + const path = await invokeCommand("ensure_settings_file"); + await invokeCommand("open_in_editor", { path }); + } catch (e) { + console.error("[command-palette] Failed to open settings:", e); + } + }, + }); +} + +// --------------------------------------------------------------------------- +// IPC event bridge +// --------------------------------------------------------------------------- + +// Listen for `run_command` dispatched by the Rust IPC handler. +window.addEventListener("agentmux-run-command", ((e: CustomEvent) => { + const id = e.detail?.id as string; + if (!commandRegistry.run(id)) { + console.warn(`[command-palette] Unknown command: ${id}`); + } +}) as EventListener); diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 8b131ff3b..554f5c30c 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -670,6 +670,11 @@ function registerGlobalKeys() { return true; }); globalChordMap.set("Ctrl:Shift:s", splitBlockKeys); + + globalKeyMap.set("Ctrl:p", () => { + modalsModel.pushModal("CommandPaletteModal"); + return true; + }); } function getAllGlobalKeyBindings(): string[] { diff --git a/frontend/wave.ts b/frontend/wave.ts index 1643e3911..ccea64002 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { App } from "@/app/app"; +import { registerDefaultCommands } from "@/app/store/command-registry"; import { globalRefocus, registerControlShiftStateUpdateHandler, @@ -648,6 +649,7 @@ async function initWave(initOpts: AgentMuxInitOpts) { t = performance.now(); registerGlobalKeys(); + registerDefaultCommands(); registerControlShiftStateUpdateHandler(); tlog("registerKeys", t); From 2ec4084544dd44acbb65e797cb3a1d5d6123de6c Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 23:35:30 -0700 Subject: [PATCH 4/6] chore: bump version to 0.33.58 --- Cargo.lock | 8 ++++---- agentmux-cef/Cargo.toml | 2 +- agentmux-launcher/Cargo.toml | 2 +- agentmux-srv/Cargo.toml | 2 +- agentmux-wsh/Cargo.toml | 2 +- package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aaaa8c0ce..01f69465b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agentmux-cef" -version = "0.33.57" +version = "0.33.58" dependencies = [ "axum 0.8.8", "cef", @@ -32,7 +32,7 @@ dependencies = [ [[package]] name = "agentmux-launcher" -version = "0.33.57" +version = "0.33.58" dependencies = [ "windows-sys 0.59.0", "winres", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "agentmux-srv" -version = "0.33.57" +version = "0.33.58" dependencies = [ "async-stream", "axum 0.7.9", @@ -77,7 +77,7 @@ dependencies = [ [[package]] name = "agentmux-wsh" -version = "0.33.57" +version = "0.33.58" dependencies = [ "base64", "clap", diff --git a/agentmux-cef/Cargo.toml b/agentmux-cef/Cargo.toml index ccbddd552..dde381f15 100644 --- a/agentmux-cef/Cargo.toml +++ b/agentmux-cef/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-cef" -version = "0.33.57" +version = "0.33.58" description = "AgentMux CEF Host — Pinned Chromium renderer replacing system WebView2" authors = ["AgentMux Corp"] edition = "2021" diff --git a/agentmux-launcher/Cargo.toml b/agentmux-launcher/Cargo.toml index d28ffd38c..650275337 100644 --- a/agentmux-launcher/Cargo.toml +++ b/agentmux-launcher/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-launcher" -version = "0.33.57" +version = "0.33.58" description = "AgentMux Launcher — tiny exe that sets DLL path then spawns the CEF host" authors = ["AgentMux Corp"] edition = "2021" diff --git a/agentmux-srv/Cargo.toml b/agentmux-srv/Cargo.toml index 6df7cfe94..955741c62 100644 --- a/agentmux-srv/Cargo.toml +++ b/agentmux-srv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-srv" -version = "0.33.57" +version = "0.33.58" edition = "2021" description = "AgentMux Rust backend server" diff --git a/agentmux-wsh/Cargo.toml b/agentmux-wsh/Cargo.toml index dec970cfd..d56eff1e3 100644 --- a/agentmux-wsh/Cargo.toml +++ b/agentmux-wsh/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux-wsh" -version = "0.33.57" +version = "0.33.58" edition = "2021" description = "Shell integration CLI for AgentMux" diff --git a/package.json b/package.json index 05e120504..fd9311768 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "productName": "AgentMux", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.33.57", + "version": "0.33.58", "homepage": "https://github.com/agentmuxai/agentmux", "build": { "appId": "ai.agentmux.app" From 7cbbccb60e49ae50cc9dbd53e73aa86a5acdfbb3 Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 23:35:45 -0700 Subject: [PATCH 5/6] chore: sync package-lock.json to 0.33.58 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bec44202..977cb1dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentmux", - "version": "0.33.57", + "version": "0.33.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentmux", - "version": "0.33.57", + "version": "0.33.58", "license": "Apache-2.0", "workspaces": [ "docs" From eb67f4cea8b1a929b75aa2b51e24727e8df4a4ce Mon Sep 17 00:00:00 2001 From: AgentA Date: Mon, 6 Apr 2026 23:36:04 -0700 Subject: [PATCH 6/6] feat(dnd): tear-off window matches pane size and cursor grab position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New window is sized to match the torn-off pane's on-screen dimensions and positioned so the cursor lands at the same relative point within the window as it was within the pane when the drag started. - TileLayout.win32.tsx: capture paneRect (screen coords) and grabOffset (cursor offset within pane) in onDragStart via getBoundingClientRect - DragItemPayload: add optional paneRect + grabOffset fields - CrossWindowDragMonitor.win32.tsx: forward paneRect/grabOffset through performTearOff → openWindowAtPosition - CrossWindowDragMonitor.tsx/.platform.tsx: export PaneRect, GrabOffset types - custom.d.ts + cef-api.ts: extend openWindowAtPosition with width, height, grabOffsetX, grabOffsetY (all optional, backward-compatible) - drag.rs: use passed dimensions (min 400×300); position from grab offset instead of hardcoded center+16px; falls back to 1200×800 / top-center if frontend doesn't provide size (e.g. tab tearoff) Co-Authored-By: Claude Sonnet 4.6 --- agentmux-cef/src/commands/drag.rs | 18 +++++++++---- .../drag/CrossWindowDragMonitor.platform.tsx | 2 +- frontend/app/drag/CrossWindowDragMonitor.tsx | 2 +- .../app/drag/CrossWindowDragMonitor.win32.tsx | 19 ++++++++++--- frontend/layout/lib/TileLayout.win32.tsx | 27 +++++++++++++++++-- frontend/types/custom.d.ts | 2 +- frontend/util/cef-api.ts | 11 ++++++-- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/agentmux-cef/src/commands/drag.rs b/agentmux-cef/src/commands/drag.rs index ae0e200d0..e25eff1b3 100644 --- a/agentmux-cef/src/commands/drag.rs +++ b/agentmux-cef/src/commands/drag.rs @@ -275,15 +275,23 @@ pub fn open_window_at_position(state: &Arc, args: &serde_json::Value) let window_id = uuid::Uuid::new_v4(); let label = format!("window-{}", window_id.simple()); - let win_w = 1200i32; - let win_h = 800i32; + // Use pane dimensions from the frontend if provided; fall back to defaults. + let win_w = args.get("width").and_then(|v| v.as_f64()).map(|v| (v as i32).max(400)).unwrap_or(1200); + let win_h = args.get("height").and_then(|v| v.as_f64()).map(|v| (v as i32).max(300)).unwrap_or(800); - // Position so cursor lands near top-center of title bar - let pos_x = ((screen_x - win_w as f64 / 2.0).max(0.0)) as i32; - let pos_y = ((screen_y - 16.0).max(0.0)) as i32; + // Cursor grab offset within the pane (where the user grabbed it). + // Positions the new window so the cursor lands at the same relative point. + // Falls back to top-center / title-bar-offset if not provided. + let grab_dx = args.get("grabOffsetX").and_then(|v| v.as_f64()).map(|v| v as i32).unwrap_or(win_w / 2); + let grab_dy = args.get("grabOffsetY").and_then(|v| v.as_f64()).map(|v| v as i32).unwrap_or(20); + + let pos_x = ((screen_x as i32) - grab_dx).max(0); + let pos_y = ((screen_y as i32) - grab_dy).max(0); tracing::info!( label = %label, pos_x = %pos_x, pos_y = %pos_y, + win_w = %win_w, win_h = %win_h, + grab_dx = %grab_dx, grab_dy = %grab_dy, workspace_id = %workspace_id, "[dnd:cef] open_window_at_position" ); diff --git a/frontend/app/drag/CrossWindowDragMonitor.platform.tsx b/frontend/app/drag/CrossWindowDragMonitor.platform.tsx index 9e4f8df54..4dece6bb3 100644 --- a/frontend/app/drag/CrossWindowDragMonitor.platform.tsx +++ b/frontend/app/drag/CrossWindowDragMonitor.platform.tsx @@ -5,4 +5,4 @@ // at build time by Vite's platformResolve plugin. // Do NOT import this file directly in application code. export { CrossWindowDragMonitor, setCurrentDragPayload, getCurrentDragPayload } from "./CrossWindowDragMonitor.win32"; -export type { DragItemPayload } from "./CrossWindowDragMonitor.win32"; +export type { DragItemPayload, GrabOffset, PaneRect } from "./CrossWindowDragMonitor.win32"; diff --git a/frontend/app/drag/CrossWindowDragMonitor.tsx b/frontend/app/drag/CrossWindowDragMonitor.tsx index ad803b42e..78ef52ed4 100644 --- a/frontend/app/drag/CrossWindowDragMonitor.tsx +++ b/frontend/app/drag/CrossWindowDragMonitor.tsx @@ -5,4 +5,4 @@ // Import from this file directly (e.g. `import { setCurrentDragPayload } from "@/app/drag/CrossWindowDragMonitor"`) // and Vite's platformResolve plugin will pick the right .win32/.darwin/.linux variant. export { CrossWindowDragMonitor, setCurrentDragPayload, getCurrentDragPayload } from "./CrossWindowDragMonitor.platform"; -export type { DragItemPayload } from "./CrossWindowDragMonitor.platform"; +export type { DragItemPayload, GrabOffset, PaneRect } from "./CrossWindowDragMonitor.platform"; diff --git a/frontend/app/drag/CrossWindowDragMonitor.win32.tsx b/frontend/app/drag/CrossWindowDragMonitor.win32.tsx index 72597d80d..a1e35bb02 100644 --- a/frontend/app/drag/CrossWindowDragMonitor.win32.tsx +++ b/frontend/app/drag/CrossWindowDragMonitor.win32.tsx @@ -30,8 +30,11 @@ import type { JSX } from "solid-js"; import type { LayoutNode } from "@/layout/lib/types"; // Shared drag state set by TileLayout / TabBar drag handlers +export type PaneRect = { x: number; y: number; w: number; h: number }; +export type GrabOffset = { dx: number; dy: number }; + export type DragItemPayload = - | { kind: "tile"; node: LayoutNode } + | { kind: "tile"; node: LayoutNode; paneRect?: PaneRect; grabOffset?: GrabOffset } | { kind: "tab"; tabId: string; workspaceId: string; isPinned: boolean }; // Module-level drag state so TileLayout/TabBar can set it before dragend fires @@ -190,7 +193,7 @@ async function handleCrossWindowDragEnd(payload: DragItemPayload, sourceWindow: await performCrossWindowDrop(dragType, dragPayloadForApi, workspace.oid, activeTabId); await api.completeCrossDrag(dragId, targetWindow, cursorPoint.x, cursorPoint.y); } else if (!targetWindow) { - await performTearOff(dragType, dragPayloadForApi, workspace.oid, activeTabId, cursorPoint.x, cursorPoint.y); + await performTearOff(dragType, dragPayloadForApi, workspace.oid, activeTabId, cursorPoint.x, cursorPoint.y, payload); await api.completeCrossDrag(dragId, null, cursorPoint.x, cursorPoint.y); try { await api.releaseDragCapture(); } catch {} } else { @@ -216,9 +219,13 @@ async function performTearOff( sourceWsId: string, sourceTabId: string, screenX: number, - screenY: number + screenY: number, + dragPayload: DragItemPayload | null ) { const api = getApi(); + const paneRect = dragPayload?.kind === "tile" ? dragPayload.paneRect : undefined; + const grabOffset = dragPayload?.kind === "tile" ? dragPayload.grabOffset : undefined; + if (dragType === "pane" && payload.blockId) { const newWsId = await WorkspaceService.TearOffBlock(payload.blockId, sourceTabId, sourceWsId, true); if (newWsId) { @@ -234,7 +241,11 @@ async function performTearOff( } as LayoutTreeDeleteNodeAction); } } - await api.openWindowAtPosition(screenX, screenY, newWsId); + await api.openWindowAtPosition( + screenX, screenY, newWsId, + paneRect?.w, paneRect?.h, + grabOffset?.dx, grabOffset?.dy + ); } } else if (dragType === "tab" && payload.tabId) { const newWsId = await WorkspaceService.TearOffTab(payload.tabId, sourceWsId); diff --git a/frontend/layout/lib/TileLayout.win32.tsx b/frontend/layout/lib/TileLayout.win32.tsx index 806b40d4f..2e737328f 100644 --- a/frontend/layout/lib/TileLayout.win32.tsx +++ b/frontend/layout/lib/TileLayout.win32.tsx @@ -24,6 +24,7 @@ import { TileLayoutContents, } from "./types"; import { determineDropDirection } from "./utils"; +import type { GrabOffset, PaneRect } from "@/app/drag/CrossWindowDragMonitor"; import { setCurrentDragPayload } from "@/app/drag/CrossWindowDragMonitor"; export const tileItemType = "TILE_ITEM"; @@ -395,12 +396,34 @@ const DisplayNode = (props: DisplayNodeProps) => { nativeSetDragImage(img, offsetX, offsetY); } }, - onDragStart: () => { + onDragStart: ({ location }) => { globalDragNodeId = props.node.id; globalDragLayoutModel = props.layoutModel; props.layoutModel.activeDrag._set(true); setIsDragging(true); - setCurrentDragPayload({ kind: "tile", node: props.node }); + // Capture pane screen rect and grab offset for tear-off sizing. + let paneRect: PaneRect | undefined; + let grabOffset: GrabOffset | undefined; + if (tileNodeRef) { + const rect = tileNodeRef.getBoundingClientRect(); + // Convert viewport-relative rect to screen coordinates. + // window.outerHeight - window.innerHeight approximates the browser chrome height. + const chromeH = window.outerHeight - window.innerHeight; + const screenLeft = window.screenX ?? 0; + const screenTop = window.screenY ?? 0; + paneRect = { + x: rect.left + screenLeft, + y: rect.top + screenTop + chromeH, + w: Math.round(rect.width), + h: Math.round(rect.height), + }; + const input = location.current.input; + grabOffset = { + dx: Math.round((input.clientX ?? 0) - rect.left), + dy: Math.round((input.clientY ?? 0) - rect.top), + }; + } + setCurrentDragPayload({ kind: "tile", node: props.node, paneRect, grabOffset }); }, onDrop: () => { globalDragNodeId = null; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 3fa0990d5..ea33df18e 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -172,7 +172,7 @@ declare global { screenY: number ) => Promise; cancelCrossDrag: (dragId: string) => Promise; - openWindowAtPosition: (screenX: number, screenY: number, workspaceId?: string) => Promise; + openWindowAtPosition: (screenX: number, screenY: number, workspaceId?: string, width?: number, height?: number, grabOffsetX?: number, grabOffsetY?: number) => Promise; setDragCursor: () => Promise; restoreDragCursor: () => Promise; releaseDragCapture: () => Promise; diff --git a/frontend/util/cef-api.ts b/frontend/util/cef-api.ts index aed9ef078..0b1af7b81 100644 --- a/frontend/util/cef-api.ts +++ b/frontend/util/cef-api.ts @@ -560,8 +560,15 @@ export function buildCefApi(): AppApi { cancelCrossDrag: async (dragId: string) => { await invokeCommand("cancel_cross_drag", { dragId }); }, - openWindowAtPosition: async (screenX: number, screenY: number, workspaceId?: string) => { - return await invokeCommand("open_window_at_position", { screenX, screenY, workspaceId: workspaceId ?? "" }); + openWindowAtPosition: async (screenX: number, screenY: number, workspaceId?: string, width?: number, height?: number, grabOffsetX?: number, grabOffsetY?: number) => { + return await invokeCommand("open_window_at_position", { + screenX, screenY, + workspaceId: workspaceId ?? "", + ...(width != null && { width }), + ...(height != null && { height }), + ...(grabOffsetX != null && { grabOffsetX }), + ...(grabOffsetY != null && { grabOffsetY }), + }); }, // --- Drag cursor & helpers ---