Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3918,9 +3918,10 @@ export default function App() {
return isNavigationIntentCurrent(navigationIntentSeq);
}, [enqueueNavigationWithIntent, isNavigationIntentCurrent, noteNavigationIntent, singleSurfaceLayout, state.running, t]);

// Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and
// recent sessions. Sessions are snapshotted on open so the list is stable
// while the palette is up; extension actions follow the same snapshot rule.
// Command palette: F1 (every platform, matching VSCode) opens a fuzzy
// navigator over commands and recent sessions. Sessions are snapshotted on
// open so the list is stable while the palette is up; extension actions
// follow the same snapshot rule.
const openPalette = useCallback(async () => {
closeTransientOverlays();
setPaletteOpen(true);
Expand Down
18 changes: 15 additions & 3 deletions desktop/frontend/src/__tests__/keyboard-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isCloseTabShortcut,
isReservedComposerHistoryShortcut,
matchesShortcut,
migrateLegacyCustomShortcuts,
shortcutAcceptsCombo,
shortcutConflict,
shortcutDefinition,
Expand Down Expand Up @@ -69,10 +70,21 @@ eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "linux"), true, "Ctrl+W clo
for (const platform of ["darwin", "windows", "linux"] satisfies ShortcutPlatform[]) {
eq(isCloseTabShortcut(event("k", { ctrlKey: true, metaKey: true }), platform), false, `${platform} ignores non-W keys`);
eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`);
eq(matchesShortcut(event("F1"), "commandPalette.open", platform), true, `${platform} opens the palette on F1 (VSCode convention)`);
eq(matchesShortcut(event("k", { ctrlKey: true }), "commandPalette.open", platform), false, `${platform} Ctrl+K is not the palette`);
eq(matchesShortcut(event("k", { metaKey: true }), "commandPalette.open", platform), false, `${platform} Cmd+K is not the palette`);
eq(matchesShortcut(event("k", { metaKey: true }), "terminal.clear", "darwin"), true, "macOS terminal clear defaults to Cmd+K");
eq(matchesShortcut(event("k", { ctrlKey: true }), "terminal.clear", platform), platform === "darwin" ? false : true, `${platform} terminal clear matches the platform modifier`);
eq(matchesShortcut(event("k"), "terminal.clear", platform), false, `${platform} bare K is not the terminal clear chord`);
}

eq(matchesShortcut(event("k", { metaKey: true }), "commandPalette.open", "darwin"), true, "Cmd+K opens the palette on macOS");
eq(matchesShortcut(event("k", { ctrlKey: true }), "commandPalette.open", "windows"), true, "Ctrl+K opens the palette on Windows");
eq(formatShortcutCombo(defaultShortcutCombo("commandPalette.open", "darwin"), "darwin"), "F1", "formats the macOS palette shortcut as F1");
eq(formatShortcutCombo(defaultShortcutCombo("commandPalette.open", "windows"), "windows"), "F1", "formats the Windows palette shortcut as F1");
eq(formatShortcutCombo(defaultShortcutCombo("terminal.clear", "darwin"), "darwin"), "⌘K", "formats the macOS terminal clear shortcut as ⌘K");
eq(formatShortcutCombo(defaultShortcutCombo("terminal.clear", "windows"), "windows"), "Ctrl+K", "formats the Windows terminal clear shortcut as Ctrl+K");
eq(migrateLegacyCustomShortcuts({ "commandPalette.open": { key: "k", meta: true } }, "darwin")["commandPalette.open"], undefined, "legacy macOS Cmd+K palette binding is dropped so terminal.clear owns Cmd+K");
eq(migrateLegacyCustomShortcuts({ "commandPalette.open": { key: "F2" } }, "darwin")["commandPalette.open"]?.key, "F2", "custom palette bindings other than legacy Cmd+K are kept");
eq(migrateLegacyCustomShortcuts({ "commandPalette.open": { key: "k", ctrl: true } }, "darwin")["commandPalette.open"]?.key, "k", "non-darwin legacy bindings are only normalized, never dropped by the darwin migration");
eq(migrateLegacyCustomShortcuts({ "commandPalette.open": { key: "k", meta: true } }, "windows")["commandPalette.open"]?.key, "k", "the migration is a no-op on non-darwin platforms");
eq(matchesShortcut({ key: "?", shiftKey: true }, "shortcuts.show", "darwin"), true, "? opens shortcut help");
eq(matchesShortcut({ key: "+", metaKey: true, shiftKey: true }, "textSize.increase", "darwin"), true, "Cmd+Plus still increases text size");
eq(formatShortcutCombo(defaultShortcutCombo("settings.open", "darwin"), "darwin"), "⌘,", "formats mac settings shortcut");
Expand Down
52 changes: 52 additions & 0 deletions desktop/frontend/src/__tests__/terminal-clear-shortcut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Run: tsx src/__tests__/terminal-clear-shortcut.test.ts
//
// Contract test: the terminal's clear-screen-and-scrollback action lives in
// the shared shortcut registry ("terminal.clear", Cmd+K / Ctrl+K like VSCode's
// integrated terminal) and TerminalView resolves it through matchesShortcut, so
// the shortcut settings panel, conflict detection, and custom bindings all
// apply to it automatically.

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

let passed = 0;
let failed = 0;

function ok(condition: boolean, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}

const testDir = dirname(fileURLToPath(import.meta.url));
const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
const keyboardSource = readFileSync(resolve(testDir, "../lib/keyboardShortcuts.ts"), "utf8");

console.log("\nterminal clear shortcut");

// The action must be a first-class registry citizen so the settings panel and
// the shortcuts cheatsheet render it and users can rebind it.
ok(/action: "terminal\.clear"/.test(keyboardSource), "terminal.clear is a registered shortcut action");
ok(/labelKey: "shortcuts\.action\.terminalClear"/.test(keyboardSource), "terminal.clear has a settings label key");
ok(/descriptionKey: "shortcuts\.desc\.terminalClear"/.test(keyboardSource), "terminal.clear has a settings description key");
ok(/defaults: modCombo\("k"\)/.test(keyboardSource), "terminal.clear defaults to Cmd+K on macOS, Ctrl+K on Windows/Linux");
ok(/allowInEditable: true/.test(keyboardSource), "terminal.clear may fire from the terminal's editable surface");
ok(/action: "commandPalette\.open"[\s\S]*?defaults: allPlatforms\(\{ key: "F1" \}\)/.test(keyboardSource), "command palette is F1 on every platform (VSCode convention)");

// TerminalView must resolve the chord through the shared matcher, not a
// hardcoded key check, so custom bindings and conflicts are respected.
ok(/matchesShortcut\(event, "terminal\.clear", detectShortcutPlatform\(\)\)/.test(terminalViewSource), "TerminalView resolves the chord via the shared shortcut matcher");
ok(/host\.closest\("\.terminal-panel"\)/.test(terminalViewSource) && /panel\.contains\(event\.target\)/.test(terminalViewSource), "handler only fires while focus is inside the terminal panel");
ok(/event\.preventDefault\(\)/.test(terminalViewSource), "handler stops the browser from processing the chord");
ok(/event\.stopImmediatePropagation\(\)/.test(terminalViewSource), "handler suppresses other global shortcut handlers");
ok(/terminal\.clear\(\)/.test(terminalViewSource), "handler clears the full buffer (screen + scrollback)");
ok(/addEventListener\("keydown", clearTerminal, \{ capture: true \}\)/.test(terminalViewSource), "handler registers in the capture phase before xterm");
ok(/removeEventListener\("keydown", clearTerminal, \{ capture: true \}\)/.test(terminalViewSource), "handler unregisters when the terminal unmounts");

console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
13 changes: 7 additions & 6 deletions desktop/frontend/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { Command, Search } from "lucide-react";
import { useT } from "../lib/i18n";
import { useMountTransition } from "../lib/useMountTransition";

// CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's
// long-tail navigation surface. Tabs through sessions, slash-commands, and
// recent files via a single fuzzy search. The list of items is provided by
// the caller (App) so the palette stays decoupled from the controller — the
// same component will work for skills, MCP servers, and future surfaces
// once a buildItems() helper is added for them.
// CommandPalette is an F1 modal (every platform, matching VSCode's command
// palette) that surfaces the desktop app's long-tail navigation surface. Tabs
// through sessions, slash-commands, and recent files via a single fuzzy
// search. The list of items is provided by the caller (App) so the palette
// stays decoupled from the controller — the same component will work for
// skills, MCP servers, and future surfaces once a buildItems() helper is
// added for them.
//
// Interaction model:
// - Input is auto-focused on open; the first match is highlighted.
Expand Down
4 changes: 3 additions & 1 deletion desktop/frontend/src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FileText, Plus, Search, X } from "lucide-react";
import { normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode, type Mode, type TabMeta } from "../lib/types";
import { projectColorValue } from "../lib/projectColors";
import { useT } from "../lib/i18n";
import { useShortcutComboLabel } from "../lib/keyboardShortcuts";
import { Tooltip } from "./Tooltip";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { WorktreeBadge } from "./WorktreeBadge";
Expand Down Expand Up @@ -55,6 +56,7 @@ function projectAccentStyle(color?: string): CSSProperties | undefined {

export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, onOpenPalette, commandCompact = false, revealActiveSignal = 0 }: TabBarProps) {
const t = useT();
const paletteShortcut = useShortcutComboLabel("commandPalette.open");
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
const [menuTabId, setMenuTabId] = useState<string | null>(null);
Expand Down Expand Up @@ -297,7 +299,7 @@ export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose
<>
<span className="tabbar__command-text tabbar__command-text--full">{t("tabBar.commandSearch")}</span>
<span className="tabbar__command-text tabbar__command-text--compact">{t("tabBar.commandSearchCompact")}</span>
<kbd className="tabbar__command-kbd">⌘K</kbd>
<kbd className="tabbar__command-kbd">{paletteShortcut}</kbd>
</>
)}
</button>
Expand Down
20 changes: 20 additions & 0 deletions desktop/frontend/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import { useTerminalStore } from "../store/terminal";
import { registerTerminalSink, startTerminalEventBridge } from "../lib/terminalEvents";
import { observeTerminalTheme, terminalThemeForElement } from "../lib/terminalTheme";
import { detectShortcutPlatform, matchesShortcut } from "../lib/keyboardShortcuts";
import type { TerminalSessionView } from "../lib/types";

export function TerminalView({ tabId, session }: { tabId: string; session: TerminalSessionView }) {
Expand Down Expand Up @@ -34,6 +35,24 @@ export function TerminalView({ tabId, session }: { tabId: string; session: Termi
const unregister = registerTerminalSink(session.id, (bytes) => terminal.write(bytes));
const input = terminal.onData((data) => { void write(tabId, session.id, data).catch(() => {}); });
const outputResize = terminal.onResize(({ cols, rows }) => { void resize(tabId, session.id, cols, rows).catch(() => {}); });
// Clear-screen-and-scrollback lives in the shared shortcut registry as
// "terminal.clear" (Cmd+K on macOS, Ctrl+K elsewhere — VSCode's terminal
// convention), so users can rebind or inspect it from the shortcut
// settings like any other action. xterm.clear() empties the whole buffer,
// making the prompt line the new first line. The capture-phase listener
// fires before xterm's own key handling, and only while focus is anywhere
// inside the terminal panel (not just the xterm element), so the chord
// keeps its native meaning in the composer and other editors.
const clearTerminal = (event: globalThis.KeyboardEvent) => {
if (!(event.target instanceof Node)) return;
const panel = host.closest(".terminal-panel");
if (!panel || !panel.contains(event.target)) return;
if (!matchesShortcut(event, "terminal.clear", detectShortcutPlatform())) return;
event.preventDefault();
event.stopImmediatePropagation();
terminal.clear();
};
document.addEventListener("keydown", clearTerminal, { capture: true });
const fitTerminal = () => {
fit.fit();
const { cols, rows } = terminal;
Expand All @@ -45,6 +64,7 @@ export function TerminalView({ tabId, session }: { tabId: string; session: Termi
return () => {
observer?.disconnect();
stopObservingTheme();
document.removeEventListener("keydown", clearTerminal, { capture: true });
input.dispose();
outputResize.dispose();
unregister();
Expand Down
41 changes: 39 additions & 2 deletions desktop/frontend/src/lib/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ShortcutAction =
| "shell.toggle"
| "terminal.toggle"
| "terminal.newSession"
| "terminal.clear"
| "sidebar.toggle"
| "textSize.increase"
| "textSize.decrease"
Expand Down Expand Up @@ -75,7 +76,10 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
section: "global",
labelKey: "shortcuts.action.commandPalette",
descriptionKey: "shortcuts.desc.commandPalette",
defaults: modCombo("k"),
// F1 on every platform matches VSCode's command palette. macOS keeps
// Cmd+K for the terminal's clear-screen-and-scrollback action; on
// Windows/Linux Ctrl+K belongs to the terminal for the same reason.
defaults: allPlatforms({ key: "F1" }),
preventDefault: true,
allowInEditable: true,
},
Expand Down Expand Up @@ -179,6 +183,20 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
preventDefault: true,
allowInEditable: true,
},
{
action: "terminal.clear",
section: "view",
labelKey: "shortcuts.action.terminalClear",
descriptionKey: "shortcuts.desc.terminalClear",
// Matches VSCode's integrated terminal: Cmd+K / Ctrl+K while the terminal
// has focus clears the screen and the scrollback. The action is handled
// inside TerminalView (it is only meaningful while the terminal is
// focused), but it lives in the shared registry so users can rebind or
// inspect it from the shortcut settings like any other action.
defaults: modCombo("k"),
preventDefault: true,
allowInEditable: true,
},
{
action: "sidebar.toggle",
section: "view",
Expand Down Expand Up @@ -372,13 +390,32 @@ export function loadCustomShortcuts(): Partial<Record<ShortcutAction, ShortcutCo
try {
const raw = localStorage.getItem(SHORTCUTS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
cachedCustomShortcuts = normalizeCustomShortcuts(parsed);
cachedCustomShortcuts = migrateLegacyCustomShortcuts(parsed);
} catch {
cachedCustomShortcuts = {};
}
return cachedCustomShortcuts;
}

// Migrates stored custom shortcuts whose binding now collides with a new
// default. The command palette moved to F1 on macOS (VSCode convention) and
// Cmd+K became terminal.clear's default, so any user who had saved the old
// macOS default (Cmd+K for the palette) would otherwise open the palette and
// clear the terminal at once. Such a binding can no longer be saved through
// the settings UI (conflict detection), so dropping it is safe: the new F1
// default applies instead.
export function migrateLegacyCustomShortcuts(
value: unknown,
platform: ShortcutPlatform = detectShortcutPlatform(),
): Partial<Record<ShortcutAction, ShortcutCombo>> {
const out = normalizeCustomShortcuts(value);
if (platform !== "darwin") return out;
const legacyPaletteDefault: ShortcutCombo = { key: "k", meta: true };
const palette = out["commandPalette.open"];
if (palette && sameCombo(palette, legacyPaletteDefault)) delete out["commandPalette.open"];
return out;
}

export function saveCustomShortcut(action: ShortcutAction, combo: ShortcutCombo | null): void {
const next = { ...loadCustomShortcuts() };
if (combo) {
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,7 @@ export const en = {
"shortcuts.action.shellToggle": "Toggle shell",
"shortcuts.action.terminalToggle": "Toggle integrated terminal",
"shortcuts.action.terminalNewSession": "New terminal session",
"shortcuts.action.terminalClear": "Clear terminal screen",
"shortcuts.action.sidebarToggle": "Toggle sidebar",
"shortcuts.action.textSizeIncrease": "Increase text size",
"shortcuts.action.textSizeDecrease": "Decrease text size",
Expand All @@ -2428,6 +2429,7 @@ export const en = {
"shortcuts.desc.shellToggle": "Expand or collapse the shell panel.",
"shortcuts.desc.terminalToggle": "Show or hide the integrated terminal.",
"shortcuts.desc.terminalNewSession": "Open a new integrated terminal session.",
"shortcuts.desc.terminalClear": "While the terminal has focus, clear the screen and the scrollback.",
"shortcuts.desc.sidebarToggle": "Show or hide the left sidebar.",
"shortcuts.desc.textSizeIncrease": "Make transcript and UI text larger.",
"shortcuts.desc.textSizeDecrease": "Make transcript and UI text smaller.",
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3051,6 +3051,7 @@ export const zhTW: Record<DictKey, string> = {
"shortcuts.action.shellToggle": "切換 Shell",
"shortcuts.action.terminalToggle": "切換內建終端機",
"shortcuts.action.terminalNewSession": "新增終端機工作階段",
"shortcuts.action.terminalClear": "清除畫面與捲動區",
"shortcuts.action.sidebarToggle": "切換側邊欄",
"shortcuts.action.textSizeIncrease": "增大字號",
"shortcuts.action.textSizeDecrease": "減小字號",
Expand All @@ -3069,6 +3070,7 @@ export const zhTW: Record<DictKey, string> = {
"shortcuts.desc.shellToggle": "展開或折疊 Shell 面板。",
"shortcuts.desc.terminalToggle": "顯示或隱藏內建終端機。",
"shortcuts.desc.terminalNewSession": "開啟新的內建終端機工作階段。",
"shortcuts.desc.terminalClear": "終端機取得焦點時,清除畫面與捲動區。",
"shortcuts.desc.sidebarToggle": "顯示或隱藏左側邊欄。",
"shortcuts.desc.textSizeIncrease": "放大會話和介面文字。",
"shortcuts.desc.textSizeDecrease": "縮小會話和介面文字。",
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,7 @@ export const zh: Record<DictKey, string> = {
"shortcuts.action.shellToggle": "切换 Shell",
"shortcuts.action.terminalToggle": "切换内置终端",
"shortcuts.action.terminalNewSession": "新建终端会话",
"shortcuts.action.terminalClear": "清屏并清除滚动区",
"shortcuts.action.sidebarToggle": "切换侧边栏",
"shortcuts.action.textSizeIncrease": "增大字号",
"shortcuts.action.textSizeDecrease": "减小字号",
Expand All @@ -2431,6 +2432,7 @@ export const zh: Record<DictKey, string> = {
"shortcuts.desc.shellToggle": "展开或折叠 Shell 面板。",
"shortcuts.desc.terminalToggle": "显示或隐藏内置终端。",
"shortcuts.desc.terminalNewSession": "打开一个新的内置终端会话。",
"shortcuts.desc.terminalClear": "终端获得焦点时,清空屏幕和滚动区。",
"shortcuts.desc.sidebarToggle": "显示或隐藏左侧边栏。",
"shortcuts.desc.textSizeIncrease": "放大会话和界面文字。",
"shortcuts.desc.textSizeDecrease": "缩小会话和界面文字。",
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10959,7 +10959,7 @@ body > .mermaid-diagram--fullscreen {
background: rgba(0, 0, 0, 0.16);
}

/* ── command palette (⌘K) — centered modal over a dimmed backdrop ─────────── */
/* ── command palette (F1) — centered modal over a dimmed backdrop ─────────── */
.drawer-backdrop:has(> .palette) {
justify-content: center;
align-items: flex-start;
Expand Down Expand Up @@ -23346,7 +23346,7 @@ body > .mermaid-diagram--fullscreen {
color: var(--fg);
}

/* L1: command-search bar — opens the ⌘K palette. Sits after the new-tab
/* L1: command-search bar — opens the F1 palette. Sits after the new-tab
button and fills the remaining strip width like the prototype. */
.tabbar__command {
flex: 1 1 auto;
Expand Down
Loading