Skip to content
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { OAuthService } from "../services/oauth/service";
import { PosthogPluginService } from "../services/posthog-plugin/service";
import { ProcessTrackingService } from "../services/process-tracking/service";
import { ProvisioningService } from "../services/provisioning/service";
import { QuickEntryService } from "../services/quick-entry/service";
import { settingsStore } from "../services/settingsStore";
import { ShellService } from "../services/shell/service";
import { SlackIntegrationService } from "../services/slack-integration/service";
Expand Down Expand Up @@ -148,5 +149,6 @@ container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService);
container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService);
container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService);
container.bind(MAIN_TOKENS.QuickEntryService).to(QuickEntryService);

container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore);
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,5 @@ export const MAIN_TOKENS = Object.freeze({
ProvisioningService: Symbol.for("Main.ProvisioningService"),
WorkspaceService: Symbol.for("Main.WorkspaceService"),
EnrichmentService: Symbol.for("Main.EnrichmentService"),
QuickEntryService: Symbol.for("Main.QuickEntryService"),
});
33 changes: 32 additions & 1 deletion apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "reflect-metadata";
import os from "node:os";
import { app, BrowserWindow, dialog } from "electron";
import { app, BrowserWindow, dialog, globalShortcut } from "electron";
import log from "electron-log/main";
import "./utils/logger";
import "./services/index.js";
Expand All @@ -24,6 +24,7 @@ import {
trackAppEvent,
} from "./services/posthog-analytics";
import type { PosthogPluginService } from "./services/posthog-plugin/service";
import type { QuickEntryService } from "./services/quick-entry/service";
import type { SlackIntegrationService } from "./services/slack-integration/service";
import type { SuspensionService } from "./services/suspension/service";
import type { TaskLinkService } from "./services/task-link/service";
Expand Down Expand Up @@ -230,12 +231,42 @@ app.whenReady().then(async () => {
createWindow();
await initializeServices();
initializeDeepLinks();
initializeQuickEntry();
});

function initializeQuickEntry(): void {
try {
const service = container.get<QuickEntryService>(
MAIN_TOKENS.QuickEntryService,
);
service.createWindow();

const accelerator = "Alt+Space";
const ok = globalShortcut.register(accelerator, () => {
try {
service.toggle();
} catch (err) {
log.error("Quick entry toggle failed", err);
}
});
if (!ok) {
log.warn(`Failed to register global shortcut: ${accelerator}`);
} else {
log.info(`Registered quick-entry global shortcut: ${accelerator}`);
}
} catch (err) {
log.error("Failed to initialize quick entry", err);
}
}

app.on("window-all-closed", () => {
app.quit();
});

app.on("will-quit", () => {
globalShortcut.unregisterAll();
});

app.on("before-quit", async (event) => {
let lifecycleService: AppLifecycleService;
try {
Expand Down
29 changes: 29 additions & 0 deletions apps/code/src/main/services/quick-entry/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const QuickEntryServiceEvent = {
FocusInput: "focus-input",
Hide: "hide",
CreateTaskRequested: "create-task-requested",
} as const;

export interface CreateTaskRequest {
content: string;
repoPath: string;
workspaceMode: "local" | "worktree";
branch: string | null;
adapter: "claude" | "codex";
model: string | null;
reasoningLevel: string | null;
executionMode: string | null;
}

export interface QuickEntryServiceEvents {
[QuickEntryServiceEvent.FocusInput]: true;
[QuickEntryServiceEvent.Hide]: true;
[QuickEntryServiceEvent.CreateTaskRequested]: CreateTaskRequest;
}

export interface RecentRepoEntry {
id: string;
path: string;
name: string;
remoteUrl: string | null;
}
121 changes: 121 additions & 0 deletions apps/code/src/main/services/quick-entry/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import {
createQuickEntryWindow,
destroyQuickEntryWindow,
hideQuickEntryWindow,
isQuickEntryWindowFocused,
isQuickEntryWindowVisible,
showAndFocusMainWindow,
showQuickEntryWindow,
} from "../../window";
import type { FoldersService } from "../folders/service";
import {
type CreateTaskRequest,
QuickEntryServiceEvent,
type QuickEntryServiceEvents,
type RecentRepoEntry,
} from "./schemas";

const log = logger.scope("quick-entry");

const BLUR_HIDE_GRACE_MS = 120;
const SHOW_GRACE_MS = 200;

@injectable()
export class QuickEntryService extends TypedEventEmitter<QuickEntryServiceEvents> {
private suppressBlurHide = false;

constructor(
@inject(MAIN_TOKENS.FoldersService)
private readonly foldersService: FoldersService,
) {
super();
}

// Idempotent: window.ts guards against double-creation, and if the window
// was destroyed (e.g. renderer crash) this recreates it.
private ensureWindow(): void {
createQuickEntryWindow({
onBlur: () => this.handleBlur(),
});
}

createWindow(): void {
this.ensureWindow();
}

private handleBlur(): void {
if (this.suppressBlurHide) return;
// Child popups (dropdowns) briefly steal focus — grace period before hiding.
setTimeout(() => {
if (!isQuickEntryWindowVisible()) return;
if (isQuickEntryWindowFocused()) return;
this.hide();
}, BLUR_HIDE_GRACE_MS);
}

isVisible(): boolean {
return isQuickEntryWindowVisible();
}

toggle(): void {
if (this.isVisible()) {
this.hide();
} else {
this.show();
}
}

show(): void {
// Lazily recreate the window if it was destroyed (renderer crash, OOM).
this.ensureWindow();
this.suppressBlurHide = true;
const ok = showQuickEntryWindow();
if (!ok) {
this.suppressBlurHide = false;
return;
}
this.emit(QuickEntryServiceEvent.FocusInput, true);
setTimeout(() => {
this.suppressBlurHide = false;
}, SHOW_GRACE_MS);
}

hide(): void {
if (!isQuickEntryWindowVisible()) return;
hideQuickEntryWindow();
this.emit(QuickEntryServiceEvent.Hide, true);
}

requestCreateTask(request: CreateTaskRequest): void {
this.hide();
showAndFocusMainWindow();
this.emit(QuickEntryServiceEvent.CreateTaskRequested, request);
}

async getRecentRepos(limit = 8): Promise<RecentRepoEntry[]> {
const folders = await this.foldersService.getFolders();
return folders
.filter((f) => f.exists)
.sort((a, b) => {
const ta = new Date(a.lastAccessed).getTime();
const tb = new Date(b.lastAccessed).getTime();
return tb - ta;
})
.slice(0, limit)
.map((f) => ({
id: f.id,
path: f.path,
name: f.name,
remoteUrl: f.remoteUrl,
}));
}

dispose(): void {
destroyQuickEntryWindow();
log.info("Quick entry service disposed");
}
}
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { oauthRouter } from "./routers/oauth";
import { osRouter } from "./routers/os";
import { processTrackingRouter } from "./routers/process-tracking";
import { provisioningRouter } from "./routers/provisioning";
import { quickEntryRouter } from "./routers/quick-entry";
import { secureStoreRouter } from "./routers/secure-store";
import { shellRouter } from "./routers/shell";
import { skillsRouter } from "./routers/skills";
Expand Down Expand Up @@ -70,6 +71,7 @@ export const trpcRouter = router({
os: osRouter,
processTracking: processTrackingRouter,
provisioning: provisioningRouter,
quickEntry: quickEntryRouter,
sleep: sleepRouter,
suspension: suspensionRouter,
secureStore: secureStoreRouter,
Expand Down
69 changes: 69 additions & 0 deletions apps/code/src/main/trpc/routers/quick-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z } from "zod";
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import {
QuickEntryServiceEvent,
type QuickEntryServiceEvents,
} from "../../services/quick-entry/schemas";
import type { QuickEntryService } from "../../services/quick-entry/service";
import { publicProcedure, router } from "../trpc";

const getService = () =>
container.get<QuickEntryService>(MAIN_TOKENS.QuickEntryService);

function subscribeToQuickEntryEvent<K extends keyof QuickEntryServiceEvents>(
event: K,
) {
return publicProcedure.subscription(async function* (opts) {
const service = getService();
const iterable = service.toIterable(event, { signal: opts.signal });
for await (const data of iterable) {
yield data;
}
});
}

const createTaskRequestInput = z.object({
content: z.string(),
repoPath: z.string(),
workspaceMode: z.enum(["local", "worktree"]),
branch: z.string().nullable(),
adapter: z.enum(["claude", "codex"]),
model: z.string().nullable(),
reasoningLevel: z.string().nullable(),
executionMode: z.string().nullable(),
});

export const quickEntryRouter = router({
toggle: publicProcedure.mutation(() => {
getService().toggle();
}),

show: publicProcedure.mutation(() => {
getService().show();
}),

hide: publicProcedure.mutation(() => {
getService().hide();
}),

requestCreateTask: publicProcedure
.input(createTaskRequestInput)
.mutation(({ input }) => {
getService().requestCreateTask(input);
}),

getRecentRepos: publicProcedure
.input(
z.object({ limit: z.number().int().positive().optional() }).optional(),
)
.query(({ input }) => {
return getService().getRecentRepos(input?.limit);
}),

onFocusInput: subscribeToQuickEntryEvent(QuickEntryServiceEvent.FocusInput),
onHide: subscribeToQuickEntryEvent(QuickEntryServiceEvent.Hide),
onCreateTaskRequested: subscribeToQuickEntryEvent(
QuickEntryServiceEvent.CreateTaskRequested,
),
});
Loading
Loading