Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4YSG7WM42MJ9QEFKRCSNSY1
anchor: const switcher
created: 2026-10-15T03:25:42Z
norm: '1'
sig: e31dedb974ee4e34
body_hash: 1155fb5961061659
raw_hash: b9b5361c633926d0
lines: 67-68
---

a window mounts one channel Layout, which alone sets and clears switcher
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4YSG7VPSWA76RMR94GF5184
anchor: function Terminal
created: 2026-10-15T03:25:42Z
norm: '1'
sig: 9ad6b07a24fa0261
body_hash: a706d2e833775105
raw_hash: d5de7ca81842d81b
lines: 124-294
---

the terminal's Cmd+K clear must preventDefault so the window's palette shortcut skips it
12 changes: 12 additions & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { host } from "./host";
import { Rename } from "./layout/rename";
import { Navigation, type Page, WindowControls } from "./navigation";
import { EmptyProjects, InitGit, OpenProject } from "./open-project";
import { Palette } from "./palette";
import {
type AppProject,
channelProject,
Expand Down Expand Up @@ -737,6 +738,17 @@ export function App() {
</Main>
{page === "channels" && channel && <ChannelDetails channel={channel} />}
</Layout>
<Palette
channels={channels}
projects={available}
host={hello.host}
project={current?.id}
attention={attention}
onChannel={select}
onCreate={current && creatable && connected
? { name: current.name, run: () => void create() }
: undefined}
/>
{adding && <OpenProject onOpen={open} onClose={() => setAdding(false)} />}
{setup && (
<SetupProject
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/layout/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ import { useResize } from "./use-resize";

export { CHAT, type Content };

/** The mounted channel's tabs, for switchers outside its tree; a window shows one channel. */
export const switcher: { current?: { window: () => WindowState; show: (uid: string) => void } } =
{};

type Icon = ComponentType<{ className?: string }>;
type Choice = Exclude<Kind, "blank">;

Expand Down Expand Up @@ -701,10 +705,14 @@ function useLayout({ id, name, chat, changed, onTabClose, opener }: Props) {
return snapshot();
});

const show = useEffectEvent((uid: string) => activate(uid));

useLayoutEffect(() => {
host.onRename = command;
switcher.current = { window: snapshot, show };
return () => {
host.onRename = undefined;
switcher.current = undefined;
host.request({ op: "window", value: null }).catch(() => {});
};
}, []);
Expand Down
140 changes: 140 additions & 0 deletions apps/app/src/palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";

import {
CommandPalette,
type CommandPaletteCommandItem,
type CommandPaletteItem,
type CommandPaletteShortcut,
} from "@ace/ui";
import type { Listing, WindowState } from "@ace/host/protocol";

import type { Outcome } from "./attention";
import { desktop } from "./desktop";
import { switcher } from "./layout/layout";
import { type AppProject, channelProject, label } from "./projects";

function action(
id: string,
label: string,
group: string,
key?: string,
shift?: boolean,
): CommandPaletteCommandItem {
const shortcut: CommandPaletteShortcut | undefined = key
? { key, modifiers: shift ? ["mod", "shift"] : ["mod"] }
: undefined;
return { kind: "command", id, label, group, shortcut };
}

/** Each id is a window action that `WindowControls`, or the app for updates, already handles. */
const ACTIONS = [
action("dashboard", "Dashboard", "Go to", "1"),
action("channels", "Channels", "Go to", "2"),
action("issues", "Issues", "Go to", "3"),
action("prs", "Pull Requests", "Go to", "4"),
action("project-open", "Open Folder…", "Project", "o"),
action("settings", "Settings", "Ace", ","),
...(desktop ? [action("updates", "Check for Updates…", "Ace")] : []),
action("nav-toggle", "Toggle Navigation", "View", "b"),
action("channels-toggle", "Toggle Channels Sidebar", "View", "b", true),
];

const CREATE = "channel-new";

function channelItems(
channels: Listing[],
projects: AppProject[],
host: string,
current: string | undefined,
attention: Map<string, Outcome>,
): CommandPaletteItem[] {
const names = new Map(projects.map((project) => [project.id, label(project, host)]));
return channels.map((channel) => {
const project = channelProject(channel);
return {
kind: "session",
id: `channel:${channel.id}`,
label: channel.host === host ? channel.name : `${channel.name} · ${channel.host}`,
group: names.get(project)!,
keywords: channel.summary ? [channel.summary] : undefined,
archived: channel.state === "archived",
// Other projects' channels wait for a search so the current project's stay in reach.
hiddenUntilSearch: project !== current,
status: channel.state === "running" && channel.busy
? "busy"
: attention.get(channel.id) === "done"
? "unread"
: undefined,
};
});
}

function tabItems(window: WindowState): CommandPaletteItem[] {
return window.tabs.filter((tab) => !tab.active).map((tab) => ({
kind: "command",
id: `tab:${tab.id}`,
// The Chat tab carries the channel's name, which the channel's own item already shows.
label: tab.type === "chat" ? "Chat" : tab.name,
group: "Tab",
}));
}

/** Mod+K searches channels in every project, the open channel's tabs, and window actions. */
export function Palette({ channels, projects, host, project, attention, onChannel, onCreate }: {
channels: Listing[];
projects: AppProject[];
host: string;
/** The current project's id. */
project?: string;
attention: Map<string, Outcome>;
onChannel: (channel: Listing) => void;
/** Starts a channel in the current project, when one can start there. */
onCreate?: { name: string; run: () => void };
}) {
const [open, setOpen] = useState(false);
// Tabs are read once on open: they only change through the window the palette covers.
const [tabs, setTabs] = useState<CommandPaletteItem[]>([]);

useEffect(() => {
function key(event: KeyboardEvent) {
if ((!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
// The composer links and the terminal clears with the same keys while they have focus.
if (event.key.toLowerCase() !== "k" || event.defaultPrevented) return;
event.preventDefault();
const state = switcher.current?.window();
setTabs(state ? tabItems(state) : []);
setOpen((value) => !value);
}
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, []);

if (!open) return null;
const items: CommandPaletteItem[] = [
...(onCreate ? [action(CREATE, `New Channel in ${onCreate.name}`, "Channel")] : []),
...ACTIONS,
...tabs,
...channelItems(channels, projects, host, project, attention),
];
const listings = new Map(channels.map((channel) => [`channel:${channel.id}`, channel]));

function select(item: CommandPaletteItem) {
const channel = listings.get(item.id);
if (channel) return onChannel(channel);
if (item.id.startsWith("tab:")) return switcher.current?.show(item.id.slice("tab:".length));
if (item.id === CREATE) return onCreate?.run();
window.dispatchEvent(new Event(`ace:${item.id}`));
}

return (
<CommandPalette
open
items={items}
onOpenChange={setOpen}
onSelect={select}
placeholder="Search channels, tabs, and actions…"
emptyLabel="Nothing matches"
description="Search channels, tabs, and actions"
/>
);
}
2 changes: 2 additions & 0 deletions apps/app/src/panels/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props)
term.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.key !== "k") return true;
if (!(MAC ? event.metaKey : event.ctrlKey)) return true;
// Claims the key from the window's command palette shortcut.
event.preventDefault();
term.clear();
return false;
});
Expand Down