@saadnvd1/agent-os 0.3.647 → 0.3.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useState } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { keepRenameFocus, RowRename } from "./RowParts";
6
+
7
+ (
8
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
+ ).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root;
12
+ let host: HTMLElement;
13
+ const input = () => host.querySelector("input")!;
14
+
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ host = document.createElement("div");
18
+ document.body.appendChild(host);
19
+ root = createRoot(host);
20
+ });
21
+ afterEach(() => {
22
+ act(() => root.unmount());
23
+ host.remove();
24
+ vi.useRealTimers();
25
+ });
26
+
27
+ function mount(onDone: (n: string | null) => void, initial = "old") {
28
+ act(() => root.render(createElement(RowRename, { initial, onDone })));
29
+ }
30
+
31
+ describe("RowRename", () => {
32
+ it("retries focus the menu stole, and ignores blur until it has it", () => {
33
+ const real = window.HTMLElement.prototype.focus;
34
+ let calls = 0;
35
+ // The first focus attempts land while the menu still traps focus.
36
+ vi.spyOn(window.HTMLElement.prototype, "focus").mockImplementation(
37
+ function (this: HTMLElement) {
38
+ if (++calls > 2) real.call(this);
39
+ }
40
+ );
41
+ const done = vi.fn();
42
+ mount(done);
43
+ act(() => input().dispatchEvent(new window.FocusEvent("blur")));
44
+ expect(done).not.toHaveBeenCalled();
45
+ act(() => vi.advanceTimersByTime(150));
46
+ expect(document.activeElement).toBe(input());
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it("keeps typed text across a parent re-render (list refresh)", () => {
51
+ function Parent() {
52
+ const [n, setN] = useState(0);
53
+ return createElement(
54
+ "div",
55
+ null,
56
+ createElement("button", { onClick: () => setN(n + 1) }),
57
+ createElement(RowRename, { initial: "old", onDone: () => {} })
58
+ );
59
+ }
60
+ act(() => root.render(createElement(Parent)));
61
+ const el = input();
62
+ act(() => {
63
+ const set = Object.getOwnPropertyDescriptor(
64
+ window.HTMLInputElement.prototype,
65
+ "value"
66
+ )!.set!;
67
+ set.call(el, "typed");
68
+ el.dispatchEvent(new window.Event("input", { bubbles: true }));
69
+ });
70
+ act(() => host.querySelector("button")!.click());
71
+ expect(input()).toBe(el);
72
+ expect(input().value).toBe("typed");
73
+ });
74
+
75
+ it("saves on Enter, cancels on Escape, saves on blur once focused", () => {
76
+ const done = vi.fn();
77
+ mount(done);
78
+ act(() => vi.advanceTimersByTime(60));
79
+ const key = (k: string) =>
80
+ act(() => {
81
+ input().dispatchEvent(
82
+ new window.KeyboardEvent("keydown", { key: k, bubbles: true })
83
+ );
84
+ });
85
+ key("Enter");
86
+ expect(done).toHaveBeenLastCalledWith("old");
87
+ key("Escape");
88
+ expect(done).toHaveBeenLastCalledWith(null);
89
+ act(() => input().blur());
90
+ expect(done).toHaveBeenCalledTimes(3);
91
+ });
92
+ });
93
+
94
+ describe("keepRenameFocus", () => {
95
+ it("stops the menu returning focus only while renaming", () => {
96
+ const e = { preventDefault: vi.fn() } as unknown as Event;
97
+ keepRenameFocus(false)(e);
98
+ expect(e.preventDefault).not.toHaveBeenCalled();
99
+ keepRenameFocus(true)(e);
100
+ expect(e.preventDefault).toHaveBeenCalled();
101
+ });
102
+ });
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
6
6
  import * as DM from "@/components/ui/dropdown-menu";
7
7
  import * as CM from "@/components/ui/context-menu";
8
8
  import { cn } from "@/lib/utils";
9
+ import { keepRenameFocus } from "./RowParts";
9
10
 
10
11
  // The open row's accent: a thin rounded tick at the left edge, inside the
11
12
  // row so the list's overflow can't clip it.
@@ -41,6 +42,7 @@ export function RowShell({
41
42
  menu,
42
43
  menuLabel,
43
44
  menuButton = true,
45
+ renaming = false,
44
46
  }: {
45
47
  title: string;
46
48
  tipKey?: string;
@@ -60,6 +62,8 @@ export function RowShell({
60
62
  menuLabel: string;
61
63
  // False keeps the right-click menu but hides the ⋯ (while selecting).
62
64
  menuButton?: boolean;
65
+ // A rename input is open: closing the menu mustn't move focus off it.
66
+ renaming?: boolean;
63
67
  }) {
64
68
  const content = (
65
69
  <div
@@ -114,6 +118,7 @@ export function RowShell({
114
118
  <DM.DropdownMenuContent
115
119
  align="end"
116
120
  className={ROW_MENU_CONTENT}
121
+ onCloseAutoFocus={keepRenameFocus(renaming)}
117
122
  onClick={(e) => e.stopPropagation()}
118
123
  >
119
124
  {menu("dropdown")}
@@ -126,7 +131,10 @@ export function RowShell({
126
131
  return (
127
132
  <CM.ContextMenu>
128
133
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
129
- <CM.ContextMenuContent className={ROW_MENU_CONTENT}>
134
+ <CM.ContextMenuContent
135
+ className={ROW_MENU_CONTENT}
136
+ onCloseAutoFocus={keepRenameFocus(renaming)}
137
+ >
130
138
  {menu("context")}
131
139
  </CM.ContextMenuContent>
132
140
  </CM.ContextMenu>
@@ -76,7 +76,7 @@ export const SessionRow = memo(function SessionRow({
76
76
  );
77
77
 
78
78
  return (
79
- <div>
79
+ <div data-session-row={session.id}>
80
80
  <RowShell
81
81
  title={session.name}
82
82
  tipKey={`session:${session.id}`}
@@ -115,6 +115,7 @@ export const SessionRow = memo(function SessionRow({
115
115
  menu={menu}
116
116
  menuLabel="Session actions"
117
117
  menuButton={!selecting}
118
+ renaming={renaming}
118
119
  >
119
120
  {renaming ? (
120
121
  <RowRename
@@ -8,6 +8,7 @@ import {
8
8
  doneSummary,
9
9
  type Shelves,
10
10
  } from "@/lib/sidebar/shelves";
11
+ import { shownShelves } from "@/lib/sidebar/visible-order";
11
12
  import type { MachineGroup } from "@/lib/sidebar/machines";
12
13
  import { sidebarUiActions } from "@/stores/sidebarUi";
13
14
  import { cn } from "@/lib/utils";
@@ -124,8 +125,10 @@ export function SessionShelves({
124
125
  snoozedCollapsed: boolean;
125
126
  donePages: number;
126
127
  }) {
128
+ const folds = { doneCollapsed, snoozedCollapsed, donePages };
127
129
  const shown = shelves.done.slice(0, doneLimit(donePages));
128
130
  const hidden = shelves.done.length - shown.length;
131
+ const visible = shownShelves(shelves, folds);
129
132
  const pendingCards = usePendingCardSessions();
130
133
  const doneCards = shelves.done.reduce(
131
134
  (n, r) => n + (pendingCards.get(r.session.id) ?? 0),
@@ -210,10 +213,9 @@ export function SessionShelves({
210
213
  collapsed={snoozedCollapsed}
211
214
  onToggle={sidebarUiActions.toggleSnoozed}
212
215
  />
213
- {!snoozedCollapsed &&
214
- shelves.snoozed.map((r) => (
215
- <SessionRow key={r.session.id} row={r} muted />
216
- ))}
216
+ {visible.snoozed.map((r) => (
217
+ <SessionRow key={r.session.id} row={r} muted />
218
+ ))}
217
219
  </section>
218
220
  )}
219
221
  {shelves.done.length > 0 && (
@@ -223,8 +225,9 @@ export function SessionShelves({
223
225
  collapsed={doneCollapsed}
224
226
  onToggle={sidebarUiActions.toggleDone}
225
227
  />
226
- {!doneCollapsed &&
227
- shown.map((r) => <SessionRow key={r.session.id} row={r} muted />)}
228
+ {visible.done.map((r) => (
229
+ <SessionRow key={r.session.id} row={r} muted />
230
+ ))}
228
231
  {!doneCollapsed && hidden > 0 && (
229
232
  <button
230
233
  type="button"
@@ -7,12 +7,13 @@ import { useDocsWorkspace } from "@/data/lumifyhub/docs";
7
7
  import { useFreshStart, usePinSession } from "@/data/sessions";
8
8
  import { useOrchestratorsQuery } from "@/data/orchestrators";
9
9
  import { docsUiActions } from "@/stores/docsUi";
10
- import type { SidebarRow } from "@/lib/sidebar/shelves";
10
+ import { visibleSessionIds } from "@/lib/sidebar/visible-order";
11
11
  import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
12
12
  import { useSessionListMutations } from "./hooks/useSessionListMutations";
13
13
  import { useBulkDelete } from "./hooks/useBulkDelete";
14
14
  import { useSidebarData } from "./useSidebarData";
15
15
  import { useSessionTips } from "./useSessionTips";
16
+ import { useSessionSwitching } from "./useSessionSwitching";
16
17
  import { RowProvider, type RowContextValue } from "./RowContext";
17
18
  import { SidebarHeader } from "./SidebarHeader";
18
19
  import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
@@ -28,9 +29,6 @@ export type { SessionListProps } from "./SessionList.types";
28
29
  // Shared, so a query still loading doesn't hand rows a new array each render.
29
30
  const NO_ORCHESTRATORS: OrchestratorOverview[] = [];
30
31
 
31
- const flatten = (rows: SidebarRow[]): string[] =>
32
- rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
33
-
34
32
  // One flat list of the workspace's sessions, on shelves: Pinned, Needs
35
33
  // you, Working, In review, Done.
36
34
  export function SessionList({
@@ -59,19 +57,20 @@ export function SessionList({
59
57
 
60
58
  const orderedIds = useMemo(
61
59
  () =>
62
- flatten([
63
- ...shelves.orchestrators,
64
- ...shelves.pinned,
65
- ...shelves.needsYou,
66
- ...shelves.working,
67
- ...shelves.inReview,
68
- ...shelves.ongoing,
69
- ...shelves.snoozed,
70
- ...shelves.done,
71
- ...data.machines.flatMap((g) => g.rows),
72
- ]),
73
- [shelves, data.machines]
60
+ visibleSessionIds(shelves, data.machines, {
61
+ doneCollapsed: ui.doneCollapsed,
62
+ snoozedCollapsed: ui.snoozedCollapsed,
63
+ donePages: ui.donePages,
64
+ }),
65
+ [
66
+ shelves,
67
+ data.machines,
68
+ ui.doneCollapsed,
69
+ ui.snoozedCollapsed,
70
+ ui.donePages,
71
+ ]
74
72
  );
73
+ useSessionSwitching(orderedIds, activeSessionId, onSelect);
75
74
 
76
75
  // Rows read their handlers through the latest render, so the context only
77
76
  // changes when what rows draw does: a status push redraws the rows it
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { MessageSquare } from "lucide-react";
4
+ import { useKeybindings } from "@/hooks/useKeybindings";
5
+ import { usePaletteCommands } from "@/hooks/usePaletteCommands";
6
+ import { chordHint } from "@/lib/keybindings/chord";
7
+ import { fieldOwnsKey } from "@/lib/keybindings/focus";
8
+ import { neighbourSession } from "@/lib/sidebar/visible-order";
9
+
10
+ const PREVIOUS = "mod+up";
11
+ const NEXT = "mod+down";
12
+
13
+ // ⌘↑ / ⌘↓ (Ctrl off a Mac) select the row above or below the active one, as
14
+ // a click would, in the order the sidebar shows. Nothing happens at either end.
15
+ export function useSessionSwitching(
16
+ orderedIds: string[],
17
+ activeSessionId: string | undefined,
18
+ onSelect: (id: string) => void
19
+ ) {
20
+ const step = (by: 1 | -1) => {
21
+ const id = neighbourSession(orderedIds, activeSessionId, by);
22
+ if (!id) return;
23
+ onSelect(id);
24
+ requestAnimationFrame(() =>
25
+ document
26
+ .querySelector(`[data-session-row="${CSS.escape(id)}"]`)
27
+ ?.scrollIntoView({ block: "nearest" })
28
+ );
29
+ };
30
+ const free = (e: KeyboardEvent) => !fieldOwnsKey(e.target);
31
+ useKeybindings("session-switching", [
32
+ { id: "session.previous", keys: PREVIOUS, when: free, run: () => step(-1) },
33
+ { id: "session.next", keys: NEXT, when: free, run: () => step(1) },
34
+ ]);
35
+ usePaletteCommands("session-switching", [
36
+ {
37
+ id: "session.previous",
38
+ title: "Previous session",
39
+ group: "Sessions",
40
+ hint: chordHint(PREVIOUS),
41
+ keywords: ["switch", "up"],
42
+ icon: MessageSquare,
43
+ run: () => step(-1),
44
+ },
45
+ {
46
+ id: "session.next",
47
+ title: "Next session",
48
+ group: "Sessions",
49
+ hint: chordHint(NEXT),
50
+ keywords: ["switch", "down"],
51
+ icon: MessageSquare,
52
+ run: () => step(1),
53
+ },
54
+ ]);
55
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { keybindings, type Keybinding } from "@/lib/keybindings/registry";
5
+
6
+ // Binds chords while the caller is mounted. `when` and `run` always see the
7
+ // latest render, so callers don't memoise them.
8
+ export function useKeybindings(source: string, bindings: Keybinding[]) {
9
+ const latest = useRef(bindings);
10
+ useEffect(() => {
11
+ latest.current = bindings;
12
+ });
13
+ const keys = bindings
14
+ .map((b) => `${b.id}:${b.keys}:${b.repeat ? 1 : 0}`)
15
+ .join("|");
16
+ useEffect(() => {
17
+ const at = (id: string) => latest.current.find((b) => b.id === id)!;
18
+ return keybindings.register(
19
+ source,
20
+ latest.current.map((b) => ({
21
+ ...b,
22
+ when: (e) => at(b.id).when?.(e) ?? true,
23
+ run: () => at(b.id).run(),
24
+ }))
25
+ );
26
+ }, [source, keys]);
27
+ }
@@ -2,7 +2,7 @@
2
2
  export const BUS_BRIEF = `You are one of several coding agents running in AgentOS. Other sessions may be working on related projects at the same time, and you can talk to them with the \`aos\` command:
3
3
 
4
4
  - \`aos peers\`: list sessions, their project and what each is doing
5
- - \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id); it prints delivered, queued or FAILED
5
+ - \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id; \`orchestrator\` is your workspace's orchestrator); it prints delivered, queued or FAILED
6
6
  - \`aos inbox\`: read messages sent to you
7
7
  - \`aos history <session>\`: your conversation with a session
8
8
  - \`aos spawn <project> "<prompt>"\`: start a new agent session in a project
package/lib/bus/index.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  import { db, type Session } from "../db";
8
8
  import { getProject } from "../projects";
9
9
  import { statusDetector } from "../status-detector";
10
- import { sendChatConfirmed } from "../chat/runner";
10
+ import { chatState, sendChatConfirmed } from "../chat/runner";
11
+ import { chatActivityLine } from "../chat/activity";
12
+ import { getWorkspace } from "../workspaces";
11
13
  import type { ChatOrigin, PeerMessage } from "../chat/events";
12
14
  import { sessionRowInfo } from "../session-meta";
13
15
  import { previousNames } from "../session-names";
@@ -38,6 +40,10 @@ export interface Peer {
38
40
  hostId: string;
39
41
  running: boolean;
40
42
  doing: string | null;
43
+ // The workspace it is the orchestrator of, else null.
44
+ orchestratorOf: string | null;
45
+ // The asking session's own workspace orchestrator.
46
+ yours?: boolean;
41
47
  }
42
48
 
43
49
  function liveSessions(): Session[] {
@@ -68,19 +74,41 @@ const sessionById = (id: string | null | undefined) =>
68
74
  | undefined)
69
75
  : undefined;
70
76
 
77
+ // A chat has no pane: its worker is the state, and it can always take a
78
+ // message (queued as its next prompt), so it is never "not running".
79
+ async function peerState(
80
+ s: Session
81
+ ): Promise<{ running: boolean; subtitle: string | null }> {
82
+ if (s.view === "chat") {
83
+ const busy = chatState(s.id) === "running";
84
+ return {
85
+ running: true,
86
+ subtitle: chatActivityLine(s.id) ?? (busy ? "working" : "idle"),
87
+ };
88
+ }
89
+ const status = statusDetector.sessionExists(s.tmux_name)
90
+ ? await statusDetector.getStatus(s.tmux_name)
91
+ : "dead";
92
+ const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
93
+ return { running: info.running, subtitle: info.subtitle };
94
+ }
95
+
96
+ const orchestratorOf = (s: Session) =>
97
+ s.role === "orchestrator" && s.workspace_id
98
+ ? (getWorkspace(s.workspace_id)?.name ?? "a workspace")
99
+ : null;
100
+
71
101
  // `callerId`: the asking session. A task its orchestrator moved here also
72
102
  // sees that orchestrator, which it can message though it runs elsewhere.
73
103
  export async function listPeers(callerId?: string): Promise<Peer[]> {
74
104
  await statusDetector.refreshCache();
75
105
  const sessions = liveSessions();
76
106
  const named = candidates(sessions);
77
- const orchestrator = heldOrchestrator(sessionById(callerId));
107
+ const caller = sessionById(callerId);
108
+ const orchestrator = heldOrchestrator(caller);
78
109
  const listed = await Promise.all(
79
110
  sessions.map(async (s, i) => {
80
- const status = statusDetector.sessionExists(s.tmux_name)
81
- ? await statusDetector.getStatus(s.tmux_name)
82
- : "dead";
83
- const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
111
+ const info = await peerState(s);
84
112
  return {
85
113
  id: s.id,
86
114
  name: s.name,
@@ -90,6 +118,10 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
90
118
  hostId: s.host_id,
91
119
  running: info.running,
92
120
  doing: info.subtitle,
121
+ orchestratorOf: orchestratorOf(s),
122
+ ...(caller?.workspace_id &&
123
+ s.role === "orchestrator" &&
124
+ s.workspace_id === caller.workspace_id && { yours: true }),
93
125
  };
94
126
  })
95
127
  );
@@ -105,6 +137,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
105
137
  hostId: "remote",
106
138
  running: true,
107
139
  doing: `your orchestrator, on ${orchestrator.heldFrom}`,
140
+ orchestratorOf: null,
141
+ yours: true,
108
142
  },
109
143
  ];
110
144
  }
@@ -116,28 +150,60 @@ export function resolveHeldOrchestrator(callerId: string, ref: string) {
116
150
 
117
151
  // A live session by name, old name, project/name, id or id prefix. The note
118
152
  // says when the name meant something other than its current owner.
119
- export function resolveTarget(ref: string): {
153
+ export function resolveTarget(
154
+ ref: string,
155
+ callerId?: string | null
156
+ ): {
120
157
  session: Session;
121
158
  note?: string;
122
159
  } {
123
- const found = tryResolveTarget(ref);
160
+ const found = tryResolveTarget(ref, callerId);
124
161
  if (!found.ok) throw new Error(found.error);
125
162
  return found;
126
163
  }
127
164
 
165
+ // "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
166
+ function ownOrchestrator(
167
+ ref: string,
168
+ sessions: Session[],
169
+ callerId?: string | null
170
+ ): Session | undefined {
171
+ if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
172
+ const workspace = sessionById(callerId)?.workspace_id;
173
+ if (!workspace) return undefined;
174
+ return sessions.find(
175
+ (s) => s.role === "orchestrator" && s.workspace_id === workspace
176
+ );
177
+ }
178
+
128
179
  function tryResolveTarget(
129
- ref: string
180
+ ref: string,
181
+ callerId?: string | null
130
182
  ):
131
183
  | { ok: true; session: Session; note?: string }
132
184
  | { ok: false; none: boolean; error: string } {
133
185
  const sessions = liveSessions();
186
+ const own = ownOrchestrator(ref, sessions, callerId);
187
+ if (own) return { ok: true, session: own };
134
188
  const r = resolveRef(ref, candidates(sessions));
135
- if (!r.ok)
189
+ if (!r.ok) {
190
+ const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
191
+ ? sessions.filter((s) => s.role === "orchestrator")
192
+ : [];
193
+ if (orchestrators.length)
194
+ return {
195
+ ok: false,
196
+ none: true,
197
+ error: `You are in no workspace, so "${ref}" is ambiguous. Orchestrators: ${orchestrators
198
+ .map((s) => `${s.name} (id ${s.id.slice(0, 8)})`)
199
+ .join(", ")}`,
200
+ };
136
201
  return {
137
202
  ok: false,
138
203
  none: r.reason === "none",
139
204
  error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
140
205
  };
206
+ }
141
207
  return {
142
208
  ok: true,
143
209
  session: sessions.find((s) => s.id === r.id)!,
@@ -287,7 +353,7 @@ export async function sendMessage(opts: {
287
353
  // A held task's message to its orchestrator is kept for the holder to pull.
288
354
  const held = heldTarget(from, opts.to);
289
355
  if (from && held) return storeForHolder(from, held, body);
290
- const target = tryResolveTarget(opts.to);
356
+ const target = tryResolveTarget(opts.to, opts.fromId);
291
357
  if (!target.ok) {
292
358
  // A task still in the start queue has no session yet: hold it.
293
359
  const queued = target.none ? findQueuedTarget(opts.to) : null;
@@ -24,28 +24,6 @@ export function backgroundable(
24
24
  );
25
25
  }
26
26
 
27
- // Ctrl+B, nothing else held, and only while there is something to move;
28
- // otherwise the key is left to the page.
29
- export function backgroundKey(
30
- e: {
31
- key: string;
32
- ctrlKey: boolean;
33
- metaKey: boolean;
34
- altKey: boolean;
35
- shiftKey: boolean;
36
- repeat?: boolean;
37
- isComposing?: boolean;
38
- },
39
- available: boolean
40
- ): boolean {
41
- return (
42
- available &&
43
- e.key.toLowerCase() === "b" &&
44
- e.ctrlKey &&
45
- !(e.metaKey || e.altKey || e.shiftKey || e.repeat || e.isComposing)
46
- );
47
- }
48
-
49
27
  // Errors that mean the agent never got the request.
50
28
  const UNREACHED = new Set(["unsupported", "unavailable", "timeout"]);
51
29
 
@@ -0,0 +1,114 @@
1
+ export interface Chord {
2
+ key: string;
3
+ ctrl: boolean;
4
+ meta: boolean;
5
+ alt: boolean;
6
+ shift: boolean;
7
+ }
8
+
9
+ export interface KeyLike {
10
+ key: string;
11
+ ctrlKey: boolean;
12
+ metaKey: boolean;
13
+ altKey: boolean;
14
+ shiftKey: boolean;
15
+ }
16
+
17
+ const MODS: Record<string, "ctrl" | "meta" | "alt" | "shift"> = {
18
+ ctrl: "ctrl",
19
+ control: "ctrl",
20
+ cmd: "meta",
21
+ meta: "meta",
22
+ alt: "alt",
23
+ option: "alt",
24
+ shift: "shift",
25
+ };
26
+
27
+ const KEY_NAMES: Record<string, string> = {
28
+ esc: "escape",
29
+ space: " ",
30
+ return: "enter",
31
+ up: "arrowup",
32
+ down: "arrowdown",
33
+ left: "arrowleft",
34
+ right: "arrowright",
35
+ };
36
+
37
+ export const isMac = () =>
38
+ typeof navigator !== "undefined" &&
39
+ /mac|iphone|ipad/i.test(navigator.platform);
40
+
41
+ // "ctrl+b", "shift+tab", "meta+k". Modifiers must match exactly: ctrl+b is
42
+ // not cmd+b and not ctrl+shift+b. "mod" is Cmd on a Mac and Ctrl elsewhere.
43
+ export function parseChord(keys: string, mac: boolean = isMac()): Chord {
44
+ const chord: Chord = {
45
+ key: "",
46
+ ctrl: false,
47
+ meta: false,
48
+ alt: false,
49
+ shift: false,
50
+ };
51
+ for (const raw of keys.toLowerCase().split("+")) {
52
+ const part = raw.trim();
53
+ const mod = part === "mod" ? (mac ? "meta" : "ctrl") : MODS[part];
54
+ if (mod) chord[mod] = true;
55
+ else if (chord.key) throw new Error(`Two keys in chord "${keys}"`);
56
+ else chord.key = KEY_NAMES[part] ?? part;
57
+ }
58
+ if (!chord.key) throw new Error(`No key in chord "${keys}"`);
59
+ return chord;
60
+ }
61
+
62
+ export function matchChord(chord: Chord, e: KeyLike): boolean {
63
+ return (
64
+ e.key.toLowerCase() === chord.key &&
65
+ e.ctrlKey === chord.ctrl &&
66
+ e.metaKey === chord.meta &&
67
+ e.altKey === chord.alt &&
68
+ e.shiftKey === chord.shift
69
+ );
70
+ }
71
+
72
+ // "Ctrl+B" for display (the label users know from Claude Code).
73
+ export function formatChord(keys: string): string {
74
+ return keys
75
+ .split("+")
76
+ .map((p) => {
77
+ const t = p.trim();
78
+ return t.length === 1
79
+ ? t.toUpperCase()
80
+ : t[0].toUpperCase() + t.slice(1).toLowerCase();
81
+ })
82
+ .join("+");
83
+ }
84
+
85
+ const SYMBOLS: Record<string, string> = {
86
+ up: "↑",
87
+ down: "↓",
88
+ left: "←",
89
+ right: "→",
90
+ };
91
+
92
+ // "mod+up" as a hint: "⌘↑" on a Mac, "Ctrl+↑" elsewhere.
93
+ export function chordHint(keys: string, mac: boolean = isMac()): string {
94
+ const parts = keys.split("+").map((p) => p.trim().toLowerCase());
95
+ const label = (p: string) =>
96
+ SYMBOLS[p] ??
97
+ (p.length === 1 ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1));
98
+ if (!mac)
99
+ return parts
100
+ .map((p) =>
101
+ p === "mod" || p === "cmd" || p === "meta" ? "Ctrl" : label(p)
102
+ )
103
+ .join("+");
104
+ const sym: Record<string, string> = {
105
+ mod: "⌘",
106
+ cmd: "⌘",
107
+ meta: "⌘",
108
+ ctrl: "⌃",
109
+ alt: "⌥",
110
+ option: "⌥",
111
+ shift: "⇧",
112
+ };
113
+ return parts.map((p) => sym[p] ?? label(p)).join("");
114
+ }