1import { EnvironmentId, ThreadId, type ThreadJumpKeybindingCommand } from "@t3tools/contracts";
2import { useEffect } from "react";
3
4export type HardwareKeyboardCommand =
5 | ThreadJumpKeybindingCommand
6 | "commandPalette"
7 | "paletteNext"
8 | "palettePrevious"
9 | "paletteDismiss"
10 | "newTask"
11 | "focusSearch"
12 | "back"
13 | "files"
14 | "terminal"
15 | "review"
16 | "copyThreadReference"
17 | "toggleSidebar"
18 | "cycleHost";
19
20type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
21
22const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
23const registrationListeners = new Set<() => void>();
24let registrationVersion = 0;
25
26/**
27 * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets
28 * the first chance to consume the command, allowing focused screens to override app defaults.
29 */
30export function useHardwareKeyboardCommand(
31 command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
32 handler: CommandHandler,
33): void {
34 useEffect(() => {
35 const commands = typeof command === "string" ? [command] : command;
36 for (const command of commands) {
37 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
38 commandHandlers.add(handler);
39 handlers.set(command, commandHandlers);
40 }
41 registrationVersion += 1;
42 registrationListeners.forEach((listener) => listener());
43 return () => {
44 for (const command of commands) {
45 const commandHandlers = handlers.get(command);
46 commandHandlers?.delete(handler);
47 if (commandHandlers?.size === 0) handlers.delete(command);
48 }
49 registrationVersion += 1;
50 registrationListeners.forEach((listener) => listener());
51 };
52 }, [command, handler]);
53}
54
55export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
56 return new Set(handlers.keys());
57}
58
59export function getHardwareKeyboardCommandRegistrationVersion(): number {
60 return registrationVersion;
61}
62
63export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void {
64 registrationListeners.add(listener);
65 return () => registrationListeners.delete(listener);
66}
67
68export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
69 const commandHandlers = handlers.get(command);
70 if (!commandHandlers) return false;
71 const handlersInRegistrationOrder = Array.from(commandHandlers);
72 for (let index = handlersInRegistrationOrder.length - 1; index >= 0; index -= 1) {
73 const handler = handlersInRegistrationOrder[index];
74 if (!handler) continue;
75 if (handler(command) !== false) return true;
76 }
77 return false;
78}
79
80export function parseActiveThreadPath(pathname: string): {
81 readonly environmentId: EnvironmentId;
82 readonly threadId: ThreadId;
83} | null {
84 const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
85 if (!match?.[1] || !match[2]) return null;
86 try {
87 return {
88 environmentId: EnvironmentId.make(decodeURIComponent(match[1])),
89 threadId: ThreadId.make(decodeURIComponent(match[2])),
90 };
91 } catch {
92 return null;
93 }
94}
95
96/**
97 * The machine after `currentId` in display order, wrapping around. Starts at
98 * the first machine when `currentId` is not listed.
99 */
100export function nextEnvironmentId<T extends { readonly environmentId: string }>(
101 environments: ReadonlyArray<T>,
102 currentId: string | null,
103): T["environmentId"] | null {
104 if (environments.length < 2) return null;
105 const index = environments.findIndex((environment) => environment.environmentId === currentId);
106 return environments[(index + 1) % environments.length]?.environmentId ?? null;
107}