aidevops 3.26.1 → 3.28.0

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.
package/README.md CHANGED
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.26.1-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.28.0-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.26.1
1
+ 3.28.0
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.26.1
8
+ # Version: 3.28.0
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.26.1",
3
+ "version": "3.28.0",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -212,6 +212,8 @@ export function App(): ReactElement {
212
212
  goForward={goForward}
213
213
  selectedLocalRepoIndex={selectedLocalRepoIndex}
214
214
  setActiveSurface={setActiveSurface}
215
+ setConversationMode={setConversationMode}
216
+ setShellMode={setShellMode}
215
217
  shellMode={shellMode}
216
218
  status={status.data}
217
219
  />
@@ -20,6 +20,7 @@ import {
20
20
  FiGrid,
21
21
  FiHash,
22
22
  FiHardDrive,
23
+ FiHelpCircle,
23
24
  FiLink,
24
25
  FiLink2,
25
26
  FiList,
@@ -54,6 +55,8 @@ const surfaceIcons: Record<SurfaceIconName, IconType> = {
54
55
  globe: FiGlobe,
55
56
  grid: FiGrid,
56
57
  hardDrive: FiHardDrive,
58
+ hash: FiHash,
59
+ help: FiHelpCircle,
57
60
  link: FiLink,
58
61
  list: FiList,
59
62
  lock: FiLock,
@@ -1,10 +1,11 @@
1
1
  /* jshint esversion: 11 */
2
- import { type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
3
- import { FiBell, FiChevronLeft, FiChevronRight, FiCommand, FiCpu, FiGlobe, FiHash, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiUser } from "react-icons/fi";
2
+ import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3
+ import { FiBell, FiChevronLeft, FiChevronRight, FiCommand, FiGlobe, FiHash, FiHelpCircle, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiTerminal, FiUser } from "react-icons/fi";
4
4
  import type { GuiFileRootId, GuiStatusData } from "../../gui-shared/src";
5
5
  import { SurfaceGlyph } from "./AppNavigation";
6
6
  import type { ConversationMode, ShellMode, SurfaceId, SurfaceNavItem } from "./app-model";
7
- import { inventorySurfaceConfigs, orderedNavItems, text } from "./app-model";
7
+ import { inventorySurfaceConfigs, text } from "./app-model";
8
+ import { CommandPalette, commandPaletteShortcutQuery } from "./CommandPalette";
8
9
  import { FileExplorerSurface } from "./FileExplorerSurface";
9
10
  import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
10
11
  import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
@@ -15,7 +16,7 @@ const communityLinks = {
15
16
  x: "https://x.com/marcuswquinn",
16
17
  } as const;
17
18
 
18
- export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, setActiveSurface, shellMode, status }: {
19
+ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, setActiveSurface, setConversationMode, setShellMode, shellMode, status }: {
19
20
  activeItem: SurfaceNavItem;
20
21
  activeSectionLabel: string;
21
22
  activeSurface: SurfaceId;
@@ -27,12 +28,14 @@ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGo
27
28
  goForward: () => void;
28
29
  selectedLocalRepoIndex: number;
29
30
  setActiveSurface: (surface: SurfaceId) => void;
31
+ setConversationMode: (mode: ConversationMode) => void;
32
+ setShellMode: (mode: ShellMode) => void;
30
33
  shellMode: ShellMode;
31
34
  status: GuiStatusData;
32
35
  }) {
33
36
  return (
34
37
  <section className="app-inset" aria-label={text.workspaceLabel}>
35
- <WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} status={status} />
38
+ <WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} setConversationMode={setConversationMode} setShellMode={setShellMode} status={status} />
36
39
  <div className="workspace-scroll">
37
40
  {shellMode === "sessions"
38
41
  ? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} status={status} />
@@ -42,7 +45,7 @@ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGo
42
45
  );
43
46
  }
44
47
 
45
- function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, status }: {
48
+ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, setConversationMode, setShellMode, status }: {
46
49
  activeItem: SurfaceNavItem;
47
50
  activeSectionLabel: string;
48
51
  activeSurface: SurfaceId;
@@ -51,6 +54,8 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
51
54
  goBack: () => void;
52
55
  goForward: () => void;
53
56
  setActiveSurface: (surface: SurfaceId) => void;
57
+ setConversationMode: (mode: ConversationMode) => void;
58
+ setShellMode: (mode: ShellMode) => void;
54
59
  status: GuiStatusData;
55
60
  }): ReactElement {
56
61
  const [assistantOpen, setAssistantOpen] = useState(false);
@@ -63,30 +68,30 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
63
68
 
64
69
  useEffect(() => {
65
70
  const openCommandPalette = (event: KeyboardEvent) => {
66
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
67
- event.preventDefault();
68
- setCommandInitialQuery("");
69
- setCommandOpen(true);
70
- return;
71
- }
72
-
73
- if (event.key === "/" && !isEditableShortcutTarget(event.target)) {
74
- event.preventDefault();
75
- setCommandInitialQuery("/");
76
- setCommandOpen(true);
77
- }
71
+ handleWorkspaceShortcut(event, {
72
+ goBack,
73
+ goForward,
74
+ openCommand: (query) => {
75
+ setCommandInitialQuery(query);
76
+ setCommandOpen(true);
77
+ },
78
+ });
78
79
  };
79
80
 
80
81
  window.addEventListener("keydown", openCommandPalette);
81
82
  return () => window.removeEventListener("keydown", openCommandPalette);
82
- }, []);
83
+ }, [goBack, goForward]);
83
84
 
84
85
  const closeMenus = () => {
85
86
  setNotificationsOpen(false);
86
87
  setProfileOpen(false);
87
88
  };
88
89
 
89
- const openSurface = (surface: SurfaceId) => {
90
+ const openItem = ({ conversationMode, shellMode, surface }: { conversationMode?: ConversationMode; shellMode?: ShellMode; surface: SurfaceId }) => {
91
+ setShellMode(shellMode ?? "devices");
92
+ if (conversationMode) {
93
+ setConversationMode(conversationMode);
94
+ }
90
95
  setActiveSurface(surface);
91
96
  closeMenus();
92
97
  setCommandOpen(false);
@@ -114,28 +119,45 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
114
119
  </div>
115
120
  <div className="header-actions">
116
121
  <div className="header-action-menu">
122
+ <button aria-label="Open help" className="header-icon-button" onClick={() => openItem({ surface: "help" })} title="Help (?)" type="button">
123
+ <FiHelpCircle aria-hidden="true" />
124
+ </button>
117
125
  <button aria-expanded={notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={() => { setNotificationsOpen((current) => !current); setProfileOpen(false); }} type="button">
118
126
  <FiBell aria-hidden="true" />
119
127
  <span className="notification-dot" aria-hidden="true" />
120
128
  </button>
121
- {notificationsOpen ? <NotificationsMenu openSurface={openSurface} /> : null}
129
+ {notificationsOpen ? <NotificationsMenu openSurface={(surface) => openItem({ surface })} /> : null}
122
130
  </div>
123
- <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} type="button">
124
- <FiCpu aria-hidden="true" />
131
+ <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} title="AI sessions (_)" type="button">
132
+ <FiTerminal aria-hidden="true" />
125
133
  </button>
126
134
  <div className="header-action-menu">
127
135
  <button aria-expanded={profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={() => { setProfileOpen((current) => !current); setNotificationsOpen(false); }} title={userName} type="button">
128
136
  <span>{userInitials}</span>
129
137
  </button>
130
- {profileOpen ? <ProfileMenu openSurface={openSurface} userName={userName} /> : null}
138
+ {profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
131
139
  </div>
132
140
  </div>
133
141
  {assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
134
- {commandOpen ? <CommandPalette close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openSurface={openSurface} status={status} /> : null}
142
+ {commandOpen ? <CommandPalette activeSurface={activeSurface} close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openItem={openItem} status={status} /> : null}
135
143
  </header>
136
144
  );
137
145
  }
138
146
 
147
+ function handleWorkspaceShortcut(event: KeyboardEvent, actions: { goBack: () => void; goForward: () => void; openCommand: (query: string) => void }): void {
148
+ const shortcutQuery = commandPaletteShortcutQuery(event);
149
+ const isEditableTarget = isEditableShortcutTarget(event.target);
150
+ const commandQuery = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" ? "" : shortcutQuery;
151
+
152
+ if (commandQuery !== undefined && !isEditableTarget) {
153
+ event.preventDefault();
154
+ actions.openCommand(commandQuery);
155
+ } else if (event.key === "Backspace" && !isEditableTarget) {
156
+ event.preventDefault();
157
+ (event.shiftKey ? actions.goForward : actions.goBack)();
158
+ }
159
+ }
160
+
139
161
  function isEditableShortcutTarget(target: EventTarget | null): boolean {
140
162
  if (!(target instanceof HTMLElement)) {
141
163
  return false;
@@ -235,114 +257,6 @@ function AssistantPanel({ activeSurface, userName }: { activeSurface: SurfaceId;
235
257
  );
236
258
  }
237
259
 
238
- interface CommandPaletteItem {
239
- description: string;
240
- icon: SurfaceNavItem["icon"];
241
- id: string;
242
- label: string;
243
- searchText: string;
244
- surface: SurfaceId;
245
- tag: string;
246
- }
247
-
248
- const slashCommandItems: CommandPaletteItem[] = [
249
- { id: "slash-add-device", label: "/add-device", description: "Start device pairing setup", icon: "device", searchText: "/add-device add device pair machine", surface: "devices", tag: "Command" },
250
- { id: "slash-add-local-repo", label: "/add-local-repo", description: "Add a local repository path", icon: "folder", searchText: "/add-local-repo add local repo git", surface: "git", tag: "Command" },
251
- { id: "slash-add-remote-repo", label: "/add-remote-repo", description: "Register a remote repository", icon: "git", searchText: "/add-remote-repo add remote repo", surface: "projects", tag: "Command" },
252
- { id: "slash-add-secret", label: "/add-secret", description: "Add a protected secret reference", icon: "shield", searchText: "/add-secret add secret vault", surface: "security", tag: "Command" },
253
- { id: "slash-add-ai-provider", label: "/add-ai-provider", description: "Connect an AI provider account", icon: "users", searchText: "/add-ai-provider add ai provider oauth", surface: "aiProviders", tag: "Command" },
254
- ];
255
-
256
- const plannedChannelItems: CommandPaletteItem[] = [
257
- { id: "channel-devops", label: "#devops", description: "Chat channel for aidevops operations", icon: "message", searchText: "#devops devops channel chat", surface: "messagingAccounts", tag: "Channel" },
258
- { id: "channel-comms", label: "#comms", description: "Chat channel for communications", icon: "message", searchText: "#comms comms channel chat", surface: "messagingAccounts", tag: "Channel" },
259
- ];
260
-
261
- function CommandPalette({ close, initialQuery, openSurface, status }: { close: () => void; initialQuery: string; openSurface: (surface: SurfaceId) => void; status: GuiStatusData }): ReactElement {
262
- const [query, setQuery] = useState(initialQuery);
263
- const inputRef = useRef<HTMLInputElement>(null);
264
- const matches = useMemo(() => {
265
- const normalizedQuery = query.trim().toLowerCase();
266
- const surfaceItems: CommandPaletteItem[] = orderedNavItems.map((item) => ({
267
- description: item.description,
268
- icon: item.icon,
269
- id: `surface-${item.id}`,
270
- label: item.label,
271
- searchText: `${item.label} ${item.description}`,
272
- surface: item.id,
273
- tag: "Surface",
274
- }));
275
- const sessionItems: CommandPaletteItem[] = status.opencode_sessions.sessions.map((session) => ({
276
- description: `AI session in ${session.repo_path_ref}`,
277
- icon: "terminal",
278
- id: `session-${session.id_ref}`,
279
- label: `#${session.title}`,
280
- searchText: `#${session.title} ${session.title} ${session.repo_path_ref} ${session.agent} ${session.model}`,
281
- surface: "git",
282
- tag: "AI session",
283
- }));
284
- const directMessageItems: CommandPaletteItem[] = [{
285
- description: "Direct message thread placeholder",
286
- icon: "users",
287
- id: "dm-local-user",
288
- label: `@${status.machine.username || "local-user"}`,
289
- searchText: `@${status.machine.username || "local-user"} direct message dm person local user`,
290
- surface: "messagingAccounts",
291
- tag: "Direct message",
292
- }];
293
- const items = [...surfaceItems, ...sessionItems, ...plannedChannelItems, ...directMessageItems, ...slashCommandItems];
294
- const prefix = normalizedQuery.charAt(0);
295
- const scopedItems = prefix === "/"
296
- ? slashCommandItems
297
- : prefix === "@"
298
- ? directMessageItems
299
- : prefix === "#"
300
- ? [...sessionItems, ...plannedChannelItems]
301
- : items;
302
-
303
- return scopedItems
304
- .filter((item) => normalizedQuery.length === 0 || item.searchText.toLowerCase().includes(normalizedQuery))
305
- .slice(0, 8);
306
- }, [query, status.machine.username, status.opencode_sessions.sessions]);
307
-
308
- useEffect(() => {
309
- inputRef.current?.focus();
310
- }, []);
311
-
312
- useEffect(() => {
313
- const closeOnEscape = (event: KeyboardEvent) => {
314
- if (event.key === "Escape") {
315
- close();
316
- }
317
- };
318
-
319
- window.addEventListener("keydown", closeOnEscape);
320
- return () => window.removeEventListener("keydown", closeOnEscape);
321
- }, [close]);
322
-
323
- return (
324
- <div className="command-palette-backdrop" role="presentation">
325
- <button aria-label="Close command palette" className="command-palette-scrim" onClick={close} type="button" />
326
- <section aria-label="Command palette" className="command-palette">
327
- <label className="command-input-row">
328
- <FiSearch aria-hidden="true" />
329
- <input ref={inputRef} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Search commands, #sessions, #channels, @people, or /actions" value={query} />
330
- </label>
331
- <ul>
332
- {matches.map((item) => (
333
- <li key={item.id}>
334
- <button onClick={() => openSurface(item.surface)} type="button">
335
- <span className="surface-icon" aria-hidden="true"><SurfaceGlyph icon={item.icon} /></span>
336
- <span><strong>{item.label}</strong><small>{item.tag} · {item.description}</small></span>
337
- </button>
338
- </li>
339
- ))}
340
- </ul>
341
- </section>
342
- </div>
343
- );
344
- }
345
-
346
260
  function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
347
261
  activeItem: SurfaceNavItem;
348
262
  activeSurface: SurfaceId;
@@ -353,6 +267,7 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
353
267
  const vaultCollection = vaultCollectionForSurface(status.vault, activeSurface);
354
268
  const staticSurfaces: Partial<Record<SurfaceId, ReactNode>> = {
355
269
  overview: <OverviewSurface status={status} />,
270
+ help: <HelpSurface />,
356
271
  settings: <SettingsSurface status={status} />,
357
272
  notifications: <NotificationsSurface />,
358
273
  admin: <AdminSurface />,
@@ -406,6 +321,40 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
406
321
  return staticSurfaces[activeSurface] ?? null;
407
322
  }
408
323
 
324
+ function HelpSurface(): ReactElement {
325
+ const shortcuts = [
326
+ ["#", "Channels"],
327
+ ["_", "AI sessions"],
328
+ ["&", "Infrastructure"],
329
+ ["?", "Help"],
330
+ [">", "New terminal command"],
331
+ ["+", "Add commands"],
332
+ ["-", "Remove commands"],
333
+ ["/", "Slash commands"],
334
+ ["=", "Comms"],
335
+ ["~", "Settings and config"],
336
+ ["!", "Notifications"],
337
+ [".", "Agents"],
338
+ ["*", "Secrets and passwords"],
339
+ [":", "Emoji picker"],
340
+ ["^", "Copy current page link"],
341
+ ];
342
+
343
+ return (
344
+ <section className="settings-surface help-surface">
345
+ <div className="planned-card">
346
+ <h2>Help</h2>
347
+ <p>Use the command palette shortcuts from anywhere outside text inputs. Recent selections appear first until you keep typing to filter.</p>
348
+ </div>
349
+ <ul className="shortcut-list">
350
+ {shortcuts.map(([shortcut, label]) => <li key={shortcut}><kbd>{shortcut}</kbd><span>{label}</span></li>)}
351
+ <li><kbd>Backspace</kbd><span>Previous page</span></li>
352
+ <li><kbd>Shift Backspace</kbd><span>Next page</span></li>
353
+ </ul>
354
+ </section>
355
+ );
356
+ }
357
+
409
358
  function SettingsSurface({ status }: { status: GuiStatusData }): ReactElement {
410
359
  return (
411
360
  <section className="settings-surface">
@@ -0,0 +1,286 @@
1
+ /* jshint esversion: 11 */
2
+ import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { FiSearch } from "react-icons/fi";
5
+ import { SurfaceGlyph } from "./AppNavigation";
6
+ import type { ConversationMode, ShellMode, SurfaceId, SurfaceIconName, SurfaceNavItem } from "./app-model";
7
+ import { navGroups, orderedNavItems, utilityNavItems } from "./app-model";
8
+ import type { GuiStatusData } from "../../gui-shared/src";
9
+
10
+ interface CommandPaletteItem {
11
+ action?: "copy-current-link";
12
+ description: string;
13
+ conversationMode?: ConversationMode;
14
+ icon: SurfaceIconName;
15
+ iconGlyph?: string;
16
+ id: string;
17
+ label: string;
18
+ searchText: string;
19
+ shellMode?: ShellMode;
20
+ surface: SurfaceId;
21
+ tag: string;
22
+ }
23
+
24
+ const commandPaletteRecentStorageKey = "aidevops-gui-command-palette-recents";
25
+ const commandPaletteRecentLimit = 5;
26
+
27
+ const slashCommandItems: CommandPaletteItem[] = [
28
+ { id: "slash-add-device", label: "/add-device", description: "Start device pairing setup", icon: "device", searchText: "/add-device add device pair machine", surface: "devices", tag: "Command" },
29
+ { id: "slash-add-local-repo", label: "/add-local-repo", description: "Add a local repository path", icon: "folder", searchText: "/add-local-repo add local repo git", surface: "git", tag: "Command" },
30
+ { id: "slash-add-remote-repo", label: "/add-remote-repo", description: "Register a remote repository", icon: "git", searchText: "/add-remote-repo add remote repo", surface: "projects", tag: "Command" },
31
+ { id: "slash-add-secret", label: "/add-secret", description: "Add a protected secret reference", icon: "shield", searchText: "/add-secret add secret vault", surface: "security", tag: "Command" },
32
+ { id: "slash-add-ai-provider", label: "/add-ai-provider", description: "Connect an AI provider account", icon: "users", searchText: "/add-ai-provider add ai provider oauth", surface: "aiProviders", tag: "Command" },
33
+ ];
34
+
35
+ const addCommandItems: CommandPaletteItem[] = slashCommandItems.map((item) => {
36
+ const label = item.label.replace("/", "+");
37
+
38
+ return { ...item, id: item.id.replace("slash-", "plus-"), label, searchText: `${label} ${item.searchText}`, tag: "Add" };
39
+ });
40
+
41
+ const removeCommandItems: CommandPaletteItem[] = [
42
+ { id: "remove-device", label: "-device", description: "Remove a paired device placeholder", icon: "device", searchText: "-device remove device unpair machine", surface: "devices", tag: "Remove" },
43
+ { id: "remove-local-repo", label: "-local-repo", description: "Remove a local repository reference placeholder", icon: "folder", searchText: "-local-repo remove local repo git", surface: "git", tag: "Remove" },
44
+ { id: "remove-secret", label: "-secret", description: "Remove a protected secret reference placeholder", icon: "shield", searchText: "-secret remove secret vault password", surface: "security", tag: "Remove" },
45
+ ];
46
+
47
+ const terminalCommandItems: CommandPaletteItem[] = [
48
+ { id: "terminal-new-command", label: "> new terminal command", description: "Send a command to a new terminal session", icon: "terminal", searchText: "> terminal command new session shell", surface: "localSetup", tag: "Terminal" },
49
+ ];
50
+
51
+ const emojiItems: CommandPaletteItem[] = [
52
+ { id: "emoji-thumbs-up", label: ":thumbsup:", description: "Copy or insert 👍", icon: "message", iconGlyph: "👍", searchText: ":thumbsup thumbs up approve emoji", surface: "messagingAccounts", tag: "Emoji" },
53
+ { id: "emoji-check", label: ":white_check_mark:", description: "Copy or insert ✅", icon: "message", iconGlyph: "✅", searchText: ":white_check_mark check done emoji", surface: "messagingAccounts", tag: "Emoji" },
54
+ { id: "emoji-eyes", label: ":eyes:", description: "Copy or insert 👀", icon: "message", iconGlyph: "👀", searchText: ":eyes review look emoji", surface: "messagingAccounts", tag: "Emoji" },
55
+ ];
56
+
57
+ const plannedChannelItems: CommandPaletteItem[] = [
58
+ { id: "channel-devops", label: "#devops", description: "Chat channel for aidevops operations", icon: "hash", searchText: "#devops devops channel chat", surface: "messagingAccounts", shellMode: "sessions", conversationMode: "people", tag: "Channel" },
59
+ { id: "channel-comms", label: "#comms", description: "Chat channel for communications", icon: "hash", searchText: "#comms comms channel chat", surface: "messagingAccounts", shellMode: "sessions", conversationMode: "people", tag: "Channel" },
60
+ ];
61
+
62
+ export interface CommandPaletteSelection {
63
+ conversationMode?: ConversationMode;
64
+ shellMode?: ShellMode;
65
+ surface: SurfaceId;
66
+ }
67
+
68
+ export function CommandPalette({ activeSurface, close, initialQuery, openItem, status }: { activeSurface: SurfaceId; close: () => void; initialQuery: string; openItem: (selection: CommandPaletteSelection) => void; status: GuiStatusData }): ReactElement {
69
+ const allItems = useMemo(() => commandPaletteItems(status, activeSurface), [activeSurface, status]);
70
+ const [query, setQuery] = useState(initialQuery);
71
+ const [activeIndex, setActiveIndex] = useState(0);
72
+ const [recentIds, setRecentIds] = useState<string[]>(() => readCommandPaletteRecentIds());
73
+ const inputRef = useRef<HTMLInputElement>(null);
74
+ const matches = useMemo(() => commandPaletteMatches(allItems, query, recentIds), [allItems, query, recentIds]);
75
+
76
+ const selectItem = useCallback((item: CommandPaletteItem | undefined) => {
77
+ if (item) {
78
+ const nextRecentIds = rememberCommandPaletteItemId(item.id, recentIds);
79
+ setRecentIds(nextRecentIds);
80
+ if (item.action === "copy-current-link") {
81
+ void copyCurrentSurfaceLink(item.surface);
82
+ }
83
+ openItem({ conversationMode: item.conversationMode, shellMode: item.shellMode, surface: item.surface });
84
+ }
85
+ }, [openItem, recentIds]);
86
+
87
+ useEffect(() => {
88
+ inputRef.current?.focus();
89
+ }, []);
90
+
91
+ useEffect(() => {
92
+ setActiveIndex((current) => Math.min(current, Math.max(0, matches.length - 1)));
93
+ }, [matches.length]);
94
+
95
+ useEffect(() => {
96
+ const handlePaletteKeys = (event: KeyboardEvent) => {
97
+ if (event.key === "Escape") {
98
+ close();
99
+ } else if (event.key === "ArrowDown") {
100
+ event.preventDefault();
101
+ setActiveIndex((current) => Math.min(matches.length - 1, current + 1));
102
+ } else if (event.key === "ArrowUp") {
103
+ event.preventDefault();
104
+ setActiveIndex((current) => Math.max(0, current - 1));
105
+ } else if (event.key === "Enter") {
106
+ event.preventDefault();
107
+ selectItem(matches[activeIndex]);
108
+ }
109
+ };
110
+
111
+ window.addEventListener("keydown", handlePaletteKeys);
112
+ return () => window.removeEventListener("keydown", handlePaletteKeys);
113
+ }, [activeIndex, close, matches, selectItem]);
114
+
115
+ return createPortal(
116
+ <div className="command-palette-backdrop" role="presentation">
117
+ <button aria-label="Close command palette" className="command-palette-scrim" onClick={close} type="button" />
118
+ <section aria-label="Command palette" className="command-palette">
119
+ <label className="command-input-row">
120
+ <FiSearch aria-hidden="true" />
121
+ <input aria-activedescendant={matches[activeIndex]?.id} aria-controls="command-palette-results" aria-expanded="true" aria-autocomplete="list" ref={inputRef} onChange={(event) => { setQuery(event.currentTarget.value); setActiveIndex(0); }} placeholder="Search /commands, #channels, _sessions, &infra, ?help, .agents" role="combobox" value={query} />
122
+ </label>
123
+ <ul id="command-palette-results">
124
+ {matches.map((item, index) => (
125
+ <li key={item.id}>
126
+ <button className={index === activeIndex ? "active" : undefined} id={item.id} onClick={() => selectItem(item)} type="button">
127
+ <span className="surface-icon" aria-hidden="true">{item.iconGlyph ?? <SurfaceGlyph icon={item.icon} />}</span>
128
+ <span><strong>{item.label}</strong><small>{item.tag} · {item.description}</small></span>
129
+ </button>
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ </section>
134
+ </div>,
135
+ document.body,
136
+ );
137
+ }
138
+
139
+ export function commandPaletteShortcutQuery(event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey">): string | undefined {
140
+ if (event.metaKey || event.ctrlKey || event.altKey) {
141
+ return undefined;
142
+ }
143
+
144
+ return new Map(commandPaletteShortcutEntries).get(event.key);
145
+ }
146
+
147
+ export const commandPaletteShortcutEntries = ["#", "_", "&", "?", ">", "+", "-", "/", "=", "~", "!", ".", "*", ":", "^"].map((shortcut) => [shortcut, shortcut] as const);
148
+
149
+ export function orderCommandItemsByRecency<T extends { id: string }>(items: T[], recentIds: string[]): T[] {
150
+ const recentItems = recentIds
151
+ .map((id) => items.find((item) => item.id === id))
152
+ .filter((item): item is T => item !== undefined);
153
+ const recentItemIds = new Set(recentItems.map((item) => item.id));
154
+
155
+ return [...recentItems, ...items.filter((item) => !recentItemIds.has(item.id))];
156
+ }
157
+
158
+ export function rememberCommandPaletteItemId(itemId: string, currentIds: string[]): string[] {
159
+ const nextIds = [itemId, ...currentIds.filter((id) => id !== itemId)].slice(0, commandPaletteRecentLimit);
160
+ writeCommandPaletteRecentIds(nextIds);
161
+ return nextIds;
162
+ }
163
+
164
+ export function commandPaletteMatches(items: CommandPaletteItem[], query: string, recentIds: string[]): CommandPaletteItem[] {
165
+ const normalizedQuery = query.trim().toLowerCase();
166
+ const prefix = normalizedQuery.charAt(0);
167
+ const scopedItems = itemsForCommandPrefix(items, prefix);
168
+
169
+ if (normalizedQuery.length === 0 || commandPaletteShortcutEntries.some(([shortcut]) => shortcut === normalizedQuery)) {
170
+ return orderCommandItemsByRecency(scopedItems, recentIds).slice(0, 8);
171
+ }
172
+
173
+ return scopedItems
174
+ .filter((item) => item.searchText.toLowerCase().includes(normalizedQuery))
175
+ .slice(0, 8);
176
+ }
177
+
178
+ function commandPaletteItems(status: GuiStatusData, activeSurface: SurfaceId): CommandPaletteItem[] {
179
+ const surfaceItems: CommandPaletteItem[] = orderedNavItems.map((item) => ({
180
+ description: item.description,
181
+ icon: item.icon,
182
+ id: `surface-${item.id}`,
183
+ label: item.label,
184
+ searchText: `${item.label} ${item.description}`,
185
+ surface: item.id,
186
+ tag: surfaceTagForItem(item),
187
+ }));
188
+ const helpItems: CommandPaletteItem[] = [
189
+ { id: "help-shortcuts", label: "? shortcuts", description: "Open command palette and keyboard shortcut help", icon: "help", searchText: "? shortcuts help command palette keyboard", surface: "help", tag: "Help" },
190
+ ];
191
+ const currentLinkItems: CommandPaletteItem[] = [
192
+ { action: "copy-current-link", id: "copy-current-link", label: `^ copy ${activeSurface} link`, description: "Copy a link that can reopen this page in the web or local app", icon: "link", searchText: `^ link copy current page url ${activeSurface} aidevops://surface/${activeSurface}`, surface: activeSurface, tag: "Link" },
193
+ ];
194
+ const sessionItems: CommandPaletteItem[] = status.opencode_sessions.sessions.map((session) => ({
195
+ description: `AI session in ${session.repo_path_ref}`,
196
+ icon: "terminal",
197
+ iconGlyph: "_",
198
+ id: `session-${session.id_ref}`,
199
+ label: `_${session.title}`,
200
+ searchText: `_${session.title} ${session.title} ${session.repo_path_ref} ${session.agent} ${session.model}`,
201
+ surface: "git",
202
+ shellMode: "sessions",
203
+ conversationMode: "ai",
204
+ tag: "AI session",
205
+ }));
206
+ const directMessageItems: CommandPaletteItem[] = [{
207
+ description: "Direct message thread placeholder",
208
+ icon: "users",
209
+ id: "dm-local-user",
210
+ label: `@${status.machine.username || "local-user"}`,
211
+ searchText: `@${status.machine.username || "local-user"} direct message dm person local user`,
212
+ surface: "messagingAccounts",
213
+ tag: "Direct message",
214
+ }];
215
+
216
+ return [...surfaceItems, ...helpItems, ...sessionItems, ...plannedChannelItems, ...directMessageItems, ...slashCommandItems, ...addCommandItems, ...removeCommandItems, ...terminalCommandItems, ...emojiItems, ...currentLinkItems];
217
+ }
218
+
219
+ function itemsForCommandPrefix(items: CommandPaletteItem[], prefix: string): CommandPaletteItem[] {
220
+ const tagsByPrefix = new Map<string, string[]>([
221
+ ["/", ["Command"]],
222
+ ["@", ["Direct message"]],
223
+ ["#", ["Channel"]],
224
+ ["_", ["AI session"]],
225
+ ["&", ["Infrastructure"]],
226
+ ["?", ["Help"]],
227
+ [">", ["Terminal"]],
228
+ ["+", ["Add"]],
229
+ ["-", ["Remove"]],
230
+ ["=", ["Comms"]],
231
+ ["~", ["Settings"]],
232
+ ["!", ["Notifications"]],
233
+ [".", ["Agent"]],
234
+ ["*", ["Secret"]],
235
+ [":", ["Emoji"]],
236
+ ["^", ["Link"]],
237
+ ]);
238
+ const allowedTags = tagsByPrefix.get(prefix) ?? ["Surface"];
239
+
240
+ return items.filter((item) => allowedTags.includes(item.tag));
241
+ }
242
+
243
+ function surfaceTagForItem(item: SurfaceNavItem): string {
244
+ const explicitTags: Partial<Record<SurfaceId, string>> = {
245
+ agents: "Agent",
246
+ config: "Settings",
247
+ help: "Help",
248
+ notifications: "Notifications",
249
+ projectConfig: "Settings",
250
+ security: "Secret",
251
+ settings: "Settings",
252
+ vault: "Secret",
253
+ };
254
+ const infrastructureIds = navGroups.find((group) => group.label === "Infrastructure")?.items.map((groupItem) => groupItem.id) ?? [];
255
+ const commsIds = navGroups.filter((group) => group.mode === "comms").flatMap((group) => group.items.map((groupItem) => groupItem.id));
256
+ const utilityFallback = utilityNavItems.some((utilityItem) => utilityItem.id === item.id) ? "Surface" : undefined;
257
+
258
+ return explicitTags[item.id] ?? (infrastructureIds.includes(item.id) ? "Infrastructure" : undefined) ?? (commsIds.includes(item.id) ? "Comms" : undefined) ?? utilityFallback ?? "Surface";
259
+ }
260
+
261
+ async function copyCurrentSurfaceLink(surface: SurfaceId): Promise<void> {
262
+ const browserUrl = typeof window === "undefined" ? "" : window.location.href.split("#")[0];
263
+ const link = browserUrl.length > 0 ? `${browserUrl}#surface=${surface}` : `aidevops://surface/${surface}`;
264
+
265
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
266
+ await navigator.clipboard.writeText(link);
267
+ }
268
+ }
269
+
270
+ function readCommandPaletteRecentIds(): string[] {
271
+ try {
272
+ const storedValue = window.localStorage.getItem(commandPaletteRecentStorageKey);
273
+ const parsedValue: unknown = storedValue ? JSON.parse(storedValue) : [];
274
+ return Array.isArray(parsedValue) ? parsedValue.filter((value): value is string => typeof value === "string").slice(0, commandPaletteRecentLimit) : [];
275
+ } catch {
276
+ return [];
277
+ }
278
+ }
279
+
280
+ function writeCommandPaletteRecentIds(ids: string[]): void {
281
+ try {
282
+ window.localStorage.setItem(commandPaletteRecentStorageKey, JSON.stringify(ids.slice(0, commandPaletteRecentLimit)));
283
+ } catch {
284
+ // Ignore unavailable storage; command navigation still works without recents.
285
+ }
286
+ }
@@ -32,6 +32,8 @@ export const surfaceIconNames = {
32
32
  globe: true,
33
33
  grid: true,
34
34
  hardDrive: true,
35
+ hash: true,
36
+ help: true,
35
37
  link: true,
36
38
  list: true,
37
39
  lock: true,
@@ -48,6 +50,7 @@ export const surfaceIconNames = {
48
50
  export type SurfaceIconName = keyof typeof surfaceIconNames;
49
51
  export const surfaceIds = [
50
52
  "overview",
53
+ "help",
51
54
  "settings",
52
55
  "notifications",
53
56
  "admin",
@@ -147,6 +150,8 @@ export const text = {
147
150
  configIntro: "Read-only file explorer for ~/.config/aidevops. Contents stay hidden until redaction rules land.",
148
151
  copyPath: "Copy path",
149
152
  dashboard: "Dashboard",
153
+ help: "Help",
154
+ helpIntro: "Command shortcuts, navigation hints, and aidevops workflow guidance will collect here.",
150
155
  domains: "Domains",
151
156
  domainsIntro: "Draft domain ownership inventory across providers.",
152
157
  draftOnly: "Local browser draft only. Saving requires a write-action manifest, confirmation, and audit trail.",
@@ -396,6 +401,7 @@ export const dashboardNavItem: SurfaceNavItem = {
396
401
  };
397
402
 
398
403
  export const utilityNavItems: SurfaceNavItem[] = [
404
+ { id: "help", label: text.help, description: "Shortcuts, command palette symbols, and support", icon: "help", badge: text.planned },
399
405
  { id: "settings", label: "Settings", description: "Profile, login, notifications, language, and theme preferences", icon: "settings", badge: text.planned },
400
406
  { id: "notifications", label: "Notifications", description: "Notification inbox and delivery preferences", icon: "message", badge: text.planned },
401
407
  { id: "admin", label: "Admin", description: "Hosted administration controls", icon: "shield", badge: text.planned },
@@ -38,7 +38,7 @@ export function renderDashboardHtml(status: GuiResponseEnvelope<GuiStatusData>):
38
38
  .join("");
39
39
  const navSections = [
40
40
  { heading: "Development", items: ["Local Repos", "Remote Repos", "Secrets", "AI Providers"] },
41
- { heading: "Account", items: ["Settings", "Notifications", "Admin"] },
41
+ { heading: "Account", items: ["Help", "Settings", "Notifications", "Admin"] },
42
42
  { heading: "Operations", items: ["Dashboard", "Vault", "Agents file explorer", "Config", "Local Setup", "Routines"] },
43
43
  { heading: "Infrastructure", items: ["Devices", "VPNs & Proxies", "Apps", "Installation", "Registrars", "Hosts", "Servers"] },
44
44
  { heading: "Identities", items: ["Brands", "Domains", "Personas"] },
@@ -1066,12 +1066,14 @@ code {
1066
1066
 
1067
1067
  .command-palette-backdrop {
1068
1068
  align-items: center;
1069
- background: rgb(0 0 0 / 28%);
1069
+ backdrop-filter: blur(8px) saturate(120%);
1070
+ background: rgb(0 0 0 / 34%);
1070
1071
  display: flex;
1071
1072
  inset: 0;
1072
1073
  justify-content: center;
1073
1074
  position: fixed;
1074
1075
  z-index: 20;
1076
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
1075
1077
  }
1076
1078
 
1077
1079
  .command-palette-scrim {
@@ -1092,7 +1094,6 @@ code {
1092
1094
  max-width: min(680px, calc(100vw - 32px));
1093
1095
  padding: 14px;
1094
1096
  position: relative;
1095
- transform: translateY(-2vh);
1096
1097
  width: 100%;
1097
1098
  }
1098
1099
 
@@ -1137,7 +1138,8 @@ code {
1137
1138
  }
1138
1139
 
1139
1140
  .command-palette button:hover,
1140
- .command-palette button:focus-visible {
1141
+ .command-palette button:focus-visible,
1142
+ .command-palette button.active {
1141
1143
  background: var(--surface-muted);
1142
1144
  color: var(--accent);
1143
1145
  outline: none;
@@ -1350,6 +1352,38 @@ code {
1350
1352
  padding: 14px;
1351
1353
  }
1352
1354
 
1355
+ .shortcut-list {
1356
+ display: grid;
1357
+ gap: 8px;
1358
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1359
+ list-style: none;
1360
+ margin: 0;
1361
+ padding: 0;
1362
+ }
1363
+
1364
+ .shortcut-list li {
1365
+ align-items: center;
1366
+ background: var(--surface-elevated);
1367
+ border: 1px solid var(--border);
1368
+ border-radius: 16px;
1369
+ display: flex;
1370
+ gap: 12px;
1371
+ padding: 12px 14px;
1372
+ }
1373
+
1374
+ .shortcut-list kbd {
1375
+ align-items: center;
1376
+ background: var(--surface-muted);
1377
+ border: 1px solid var(--border);
1378
+ border-radius: 10px;
1379
+ color: var(--accent);
1380
+ display: inline-flex;
1381
+ font-weight: 900;
1382
+ justify-content: center;
1383
+ min-width: 34px;
1384
+ padding: 4px 8px;
1385
+ }
1386
+
1353
1387
  .notification-list span {
1354
1388
  color: var(--text-muted);
1355
1389
  }
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.26.1
15
+ # Version: 3.28.0
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)