aidevops 3.29.18 → 3.29.20

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.29.18-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.20-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.29.18
1
+ 3.29.20
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.29.18
8
+ # Version: 3.29.20
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.29.18",
3
+ "version": "3.29.20",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  export function readJsonObject(pathName: string): { health: "present" | "missing" | "invalid"; value: Record<string, unknown> } {
@@ -112,12 +112,34 @@ export function firstExistingPathRef(pathRefs: string[]): string | null {
112
112
  return pathRefs.find((pathRef) => existsSync(expandHome(pathRef))) ?? null;
113
113
  }
114
114
 
115
+ export function firstExecutablePathRef(pathRefs: string[]): string | null {
116
+ return pathRefs.find((pathRef) => isExecutable(expandHome(pathRef))) ?? null;
117
+ }
118
+
119
+ export function isExecutable(pathName: string): boolean {
120
+ try {
121
+ if (!statSync(pathName).isFile()) {
122
+ return false;
123
+ }
124
+ accessSync(pathName, constants.X_OK);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
115
131
  export function resolveBinary(binary: string): string | null {
132
+ const candidatePath = binaryPathCandidates(binary).find((pathName) => isExecutable(pathName));
133
+ if (candidatePath !== undefined) {
134
+ return candidatePath;
135
+ }
136
+
116
137
  try {
117
138
  const output = execFileSync("which", [binary], {
139
+ env: { ...process.env, PATH: expandedPath() },
118
140
  encoding: "utf8",
119
141
  stdio: ["ignore", "pipe", "ignore"],
120
- timeout: 200,
142
+ timeout: 350,
121
143
  }).trim();
122
144
  return output.split("\n").find((line) => line.length > 0) ?? null;
123
145
  } catch {
@@ -125,6 +147,27 @@ export function resolveBinary(binary: string): string | null {
125
147
  }
126
148
  }
127
149
 
150
+ function binaryPathCandidates(binary: string): string[] {
151
+ return expandedPath().split(":").filter(Boolean).map((dir) => join(dir, binary));
152
+ }
153
+
154
+ function expandedPath(): string {
155
+ const home = process.env.HOME ?? "";
156
+ const extraPaths = [
157
+ "/opt/homebrew/bin",
158
+ "/usr/local/bin",
159
+ "/usr/bin",
160
+ "/bin",
161
+ "/usr/sbin",
162
+ "/sbin",
163
+ join(home, ".bun/bin"),
164
+ join(home, ".local/bin"),
165
+ join(home, ".cursor/bin"),
166
+ join(home, ".qlty/bin"),
167
+ ];
168
+ return [...new Set([...(process.env.PATH ?? "").split(":"), ...extraPaths].filter(Boolean))].join(":");
169
+ }
170
+
128
171
  export function readBinaryVersion(binaryPath: string, args: string[]): string {
129
172
  if (args.length === 0) {
130
173
  return "unknown";
@@ -132,9 +175,10 @@ export function readBinaryVersion(binaryPath: string, args: string[]): string {
132
175
 
133
176
  try {
134
177
  const output = execFileSync(binaryPath, args, {
178
+ env: { ...process.env, PATH: expandedPath() },
135
179
  encoding: "utf8",
136
- stdio: ["ignore", "pipe", "ignore"],
137
- timeout: 150,
180
+ stdio: ["ignore", "pipe", "pipe"],
181
+ timeout: 650,
138
182
  }).trim();
139
183
  return output.split("\n").find((line) => line.length > 0) ?? "unknown";
140
184
  } catch {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { GuiAppActionId, GuiManagedAppSummary } from "../../gui-shared/src";
3
- import { collapseHome, expandHome, firstExistingPathRef, readBinaryVersion, resolveBinary } from "./status-adapter-utils";
3
+ import { collapseHome, expandHome, firstExecutablePathRef, firstExistingPathRef, readBinaryVersion, resolveBinary } from "./status-adapter-utils";
4
4
 
5
5
  type ManagedAppActionCommands = Partial<Record<GuiAppActionId, string>>;
6
6
 
@@ -180,7 +180,7 @@ function setupScopeActions(scope: string): ManagedAppActionCommands {
180
180
  }
181
181
 
182
182
  function managedAppSummary(definition: ManagedAppDefinition, latestVersion: string, installedVersion: string): GuiManagedAppSummary {
183
- const binaryPath = definition.binary === null ? null : resolveBinary(definition.binary);
183
+ const binaryPath = definition.binary === null ? null : executableInstallPath(definition.install_path_refs) ?? resolveBinary(definition.binary);
184
184
  const installPathRef = firstExistingPathRef(definition.install_path_refs) ?? definition.install_path_refs[0] ?? "not found";
185
185
  const installedVersionText = readManagedAppVersion(definition, binaryPath, installedVersion);
186
186
  const latestVersionText = definition.latest_version_source === "aidevops"
@@ -231,7 +231,13 @@ function readManagedAppVersion(definition: ManagedAppDefinition, binaryPath: str
231
231
  return readAppBundleVersion(existingPathRef) ?? "installed";
232
232
  }
233
233
 
234
- return readBinaryVersion(binaryPath, definition.version_args);
234
+ const versionText = readBinaryVersion(binaryPath, definition.version_args);
235
+ return versionText === "unknown" ? "installed" : versionText;
236
+ }
237
+
238
+ function executableInstallPath(pathRefs: string[]): string | null {
239
+ const executablePathRef = firstExecutablePathRef(pathRefs);
240
+ return executablePathRef === null ? null : expandHome(executablePathRef);
235
241
  }
236
242
 
237
243
  function readAppBundleVersion(pathRef: string): string | null {
@@ -239,5 +245,11 @@ function readAppBundleVersion(pathRef: string): string | null {
239
245
  return null;
240
246
  }
241
247
 
242
- return readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
248
+ const version = readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
249
+ if (version !== "unknown") {
250
+ return version;
251
+ }
252
+
253
+ const bundleVersion = readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", `${expandHome(pathRef)}/Contents/Info.plist`]);
254
+ return bundleVersion === "unknown" ? null : bundleVersion;
243
255
  }
@@ -338,7 +338,7 @@ final class DraggableTitlebarView: NSView {
338
338
  }
339
339
  }
340
340
 
341
- final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
341
+ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
342
342
  private var window: NSWindow!
343
343
  private var aboutWindow: NSWindow?
344
344
  private var titlebarToggleButton: NSButton?
@@ -416,6 +416,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
416
416
  decisionHandler(.allow)
417
417
  }
418
418
 
419
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
420
+ guard let url = navigationAction.request.url else {
421
+ return nil
422
+ }
423
+
424
+ if url.scheme == "http" || url.scheme == "https" {
425
+ NSWorkspace.shared.open(url)
426
+ }
427
+
428
+ return nil
429
+ }
430
+
419
431
  private func isLocalAppURL(_ url: URL) -> Bool {
420
432
  let host = url.host ?? ""
421
433
  return (host == "127.0.0.1" || host == "localhost") && url.port == Int(webPort)
@@ -677,11 +689,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
677
689
  private func configureWindow() {
678
690
  let configuration = WKWebViewConfiguration()
679
691
  configuration.defaultWebpagePreferences.allowsContentJavaScript = true
692
+ configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
680
693
  let userContentController = WKUserContentController()
681
694
  userContentController.add(self, name: "accentHue")
682
695
  configuration.userContentController = userContentController
683
696
  webView = WKWebView(frame: .zero, configuration: configuration)
684
697
  webView.navigationDelegate = self
698
+ webView.uiDelegate = self
685
699
  webView.translatesAutoresizingMaskIntoConstraints = false
686
700
 
687
701
  let contentContainer = NSView()
@@ -4,13 +4,9 @@ import { MachineRail, Sidebar } from "./AppNavigation";
4
4
  import { Workspace } from "./AppWorkspace";
5
5
  import type { ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
6
6
  import { DEFAULT_ACCENT_HUE, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isFontPreference, isFontSizePreference } from "./app-model";
7
+ import { type NavigationHistory, nextHistoryIndex, useNavigationHistoryKeyboard } from "./navigation-history";
7
8
  import { fetchStatus, mockedStatus } from "./status-client";
8
9
 
9
- interface NavigationHistory {
10
- entries: SurfaceId[];
11
- index: number;
12
- }
13
-
14
10
  interface WebKitBridgeWindow extends Window {
15
11
  webkit?: {
16
12
  messageHandlers?: {
@@ -37,7 +33,7 @@ export const appearanceStorageKeys = {
37
33
  } as const;
38
34
 
39
35
  const defaultSidebarWidth = 302;
40
- const minSidebarWidth = 248;
36
+ const minSidebarWidth = 300;
41
37
  const maxSidebarWidth = 520;
42
38
  export const loadingSkeletonPanelLabels = ["machine rail", "sidebar", "workspace", "status bar"] as const;
43
39
  export const loadingBrandGlyph = ">_";
@@ -93,6 +89,7 @@ export function App(): ReactElement {
93
89
  const canGoBack = navigation.index > 0;
94
90
  const canGoForward = navigation.index < navigation.entries.length - 1;
95
91
  const fileRoot = fileRootBySurface[activeSurface];
92
+ useNavigationHistoryKeyboard(setNavigation);
96
93
 
97
94
  const setActiveSurface = (surface: SurfaceId) => {
98
95
  setNavigation((current) => {
@@ -108,11 +105,11 @@ export function App(): ReactElement {
108
105
  };
109
106
 
110
107
  const goBack = () => {
111
- setNavigation((current) => ({ ...current, index: Math.max(0, current.index - 1) }));
108
+ setNavigation((current) => ({ ...current, index: nextHistoryIndex(current, -1) }));
112
109
  };
113
110
 
114
111
  const goForward = () => {
115
- setNavigation((current) => ({ ...current, index: Math.min(current.entries.length - 1, current.index + 1) }));
112
+ setNavigation((current) => ({ ...current, index: nextHistoryIndex(current, 1) }));
116
113
  };
117
114
 
118
115
  useEffect(() => {
@@ -145,7 +145,7 @@ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPrefer
145
145
 
146
146
  return (
147
147
  <aside className="app-sidebar" aria-label={text.navigationLabel}>
148
- <SidebarHeader setShellMode={setShellMode} shellMode={shellMode} />
148
+ <SidebarHeader setActiveSurface={setActiveSurface} setShellMode={setShellMode} shellMode={shellMode} />
149
149
  <nav className="sidebar-content">
150
150
  {shellMode === "devices" ? <>
151
151
  <SidebarModeTabs mode={sidebarMode} setMode={(mode) => setSidebarMode(mode as SidebarMode)} />
@@ -357,17 +357,18 @@ function SidebarItem({ activeSurface, item, recordCount, setActiveSurface, showC
357
357
  );
358
358
  }
359
359
 
360
- function SidebarHeader({ setShellMode, shellMode }: {
360
+ function SidebarHeader({ setActiveSurface, setShellMode, shellMode }: {
361
+ setActiveSurface: (surface: SurfaceId) => void;
361
362
  setShellMode: (mode: ShellMode) => void;
362
363
  shellMode: ShellMode;
363
364
  }) {
364
365
  return (
365
366
  <header className="sidebar-header">
366
367
  <div className="sidebar-titlebar">
367
- <div className="brand-lockup">
368
+ <button className="brand-lockup" onClick={() => setActiveSurface(dashboardNavItem.id)} title="Return to dashboard" type="button">
368
369
  <span className="terminal-mark" aria-hidden="true">›_</span>
369
370
  <strong>{text.aidevops}</strong>
370
- </div>
371
+ </button>
371
372
  <IconSwitch
372
373
  ariaLabel="Navigation scope"
373
374
  options={[
@@ -501,14 +502,25 @@ function SidebarFooter({ accentHue, fontPreference, fontSizePreference, setAccen
501
502
  id="font-size-slider"
502
503
  max={fontSizeOptions.length - 1}
503
504
  min="0"
504
- onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "m")}
505
+ onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "xs")}
505
506
  step="1"
506
507
  type="range"
507
508
  value={fontSizeIndex}
508
509
  />
509
- <div className="range-labels" aria-hidden="true">
510
- {fontSizeOptions.map((option) => <span className={option.value === fontSizePreference ? "active" : ""} key={option.value}>{option.label}</span>)}
511
- </div>
510
+ <fieldset className="range-labels">
511
+ <legend className="sr-only">Font size shortcuts</legend>
512
+ {fontSizeOptions.map((option) => (
513
+ <button
514
+ aria-pressed={option.value === fontSizePreference}
515
+ className={option.value === fontSizePreference ? "active" : ""}
516
+ key={option.value}
517
+ onClick={() => setFontSizePreference(option.value)}
518
+ type="button"
519
+ >
520
+ {option.label}
521
+ </button>
522
+ ))}
523
+ </fieldset>
512
524
  </div>
513
525
  <div className="font-control">
514
526
  <span id="appearance-font-selector-label">{text.font}</span>
@@ -1,5 +1,5 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type Dispatch, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, type SetStateAction, useEffect, useRef, useState } from "react";
2
+ import { type CSSProperties, type Dispatch, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, type SetStateAction, useEffect, useRef, useState } from "react";
3
3
  import type { IconType } from "react-icons";
4
4
  import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
5
5
  import { FiChevronDown, FiCode, FiDownload, FiExternalLink, FiGlobe, FiMonitor, FiRefreshCw, FiRepeat, FiTerminal, FiTrash2, FiX } from "react-icons/fi";
@@ -24,18 +24,20 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
24
24
  const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
25
25
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
26
26
  const [dismissedJobIds, setDismissedJobIds] = useState<Set<string>>(() => new Set());
27
+ const [tooltip, setTooltip] = useState<AppTooltipState | null>(null);
27
28
  const [policyJobs, setPolicyJobs] = useState<Record<string, GuiAppActionJobSummary[]>>({});
28
29
  const [policyToggles, setPolicyToggles] = useState<ManagedPolicyToggleState>(() => readManagedPolicyToggles());
29
30
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
30
31
  const managedApps = sortedManagedApps(status.managed_apps).map((app) => applyManagedPolicyToggles(app, policyToggles[app.id]));
31
32
  const visibleManagedApps = managedApps.filter((app) => managedCategoryForApp(app) === managedCategory);
33
+ const firstVisibleManagedAppId = visibleManagedApps[0]?.id ?? null;
32
34
  const visibleRecommendedApps = recommendedApps.filter((app) => recommendedAppMatchesFilters(app, recommendedPlatform, recommendedOs));
33
35
  const selectRecommendedPlatform = (value: RecommendedPlatformFilterId) => setRecommendedPlatform((current) => nextRecommendedFilterValue(current, value, "all"));
34
36
  const selectRecommendedOs = (value: RecommendedOsId) => setRecommendedOs((current) => nextRecommendedFilterValue(current, value, "all"));
35
37
 
36
38
  useEffect(() => {
37
- setExpandedAppId(visibleManagedApps[0]?.id ?? null);
38
- }, [managedCategory, status.managed_apps]);
39
+ setExpandedAppId(firstVisibleManagedAppId);
40
+ }, [firstVisibleManagedAppId]);
39
41
 
40
42
  const runningJobIdsKey = Object.values(jobs).filter((job) => job.status === "running").map((job) => job.id).sort().join("|");
41
43
 
@@ -57,7 +59,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
57
59
  }, [runningJobIdsKey]);
58
60
 
59
61
  return (
60
- <section className="apps-surface" aria-label={text.apps}>
62
+ <section className="apps-surface" aria-label={text.apps} onBlur={() => setTooltip(null)} onFocus={showFocusedAppTooltip(setTooltip)} onPointerLeave={() => setTooltip(null)} onPointerMove={showPointedAppTooltip(setTooltip)}>
61
63
  <div className="section-heading">
62
64
  <p className="eyebrow">{text.infrastructure}</p>
63
65
  <h2>{text.apps}</h2>
@@ -103,6 +105,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
103
105
  </div>
104
106
  <p className="empty-state compact-notice">Install/update actions run as allowlisted background jobs and stream inside each app panel. xterm.js with a node-pty bridge is the right next step for full TUI apps such as OpenCode; this view starts with non-interactive command logs.</p>
105
107
  </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} platform={recommendedPlatform} setOs={selectRecommendedOs} setPlatform={selectRecommendedPlatform} />}
108
+ {tooltip === null ? null : <AppTooltip tooltip={tooltip} />}
106
109
  </section>
107
110
  );
108
111
  }
@@ -151,7 +154,8 @@ function ManagedAppPanel({ app, dismissedJobIds, expanded, job, onDismissJob, on
151
154
  }
152
155
 
153
156
  function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
154
- return <span className="managed-summary-chip"><small>{label}</small><span>{value}</span></span>;
157
+ const tooltip = `${label}: ${value}`;
158
+ return <span className="managed-summary-chip" data-tooltip={tooltip} title={tooltip}><small>{label}</small><span>{value}</span></span>;
155
159
  }
156
160
 
157
161
  function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
@@ -159,7 +163,7 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
159
163
  return <span className="origin-missing">{label}: source pending</span>;
160
164
  }
161
165
 
162
- return <a href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
166
+ return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
163
167
  }
164
168
 
165
169
  function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
@@ -218,7 +222,7 @@ function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; on
218
222
  if (outputRef.current !== null) {
219
223
  outputRef.current.scrollTop = outputRef.current.scrollHeight;
220
224
  }
221
- }, [job.output]);
225
+ }, [job.output.length]);
222
226
 
223
227
  return (
224
228
  <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
@@ -594,17 +598,70 @@ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
594
598
  window.localStorage.setItem("aidevops-gui-managed-app-policy", JSON.stringify(policy));
595
599
  }
596
600
 
601
+ interface AppTooltipState {
602
+ text: string;
603
+ x: number;
604
+ y: number;
605
+ }
606
+
607
+ function showPointedAppTooltip(setTooltip: Dispatch<SetStateAction<AppTooltipState | null>>) {
608
+ return (event: ReactPointerEvent<HTMLElement>) => {
609
+ const target = tooltipTarget(event.target);
610
+ setTooltip(target === null ? null : tooltipForElement(target));
611
+ };
612
+ }
613
+
614
+ function showFocusedAppTooltip(setTooltip: Dispatch<SetStateAction<AppTooltipState | null>>) {
615
+ return (event: ReactFocusEvent<HTMLElement>) => {
616
+ const target = tooltipTarget(event.target);
617
+ if (target !== null) {
618
+ setTooltip(tooltipForElement(target));
619
+ }
620
+ };
621
+ }
622
+
623
+ function tooltipTarget(target: EventTarget): HTMLElement | null {
624
+ return target instanceof HTMLElement ? target.closest<HTMLElement>("[data-tooltip]") : null;
625
+ }
626
+
627
+ function tooltipForElement(element: HTMLElement): AppTooltipState | null {
628
+ const textValue = element.dataset.tooltip;
629
+ if (textValue === undefined || textValue.length === 0) {
630
+ return null;
631
+ }
632
+
633
+ const rect = element.getBoundingClientRect();
634
+ return { text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
635
+ }
636
+
637
+ function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
638
+ const style = {
639
+ "--tooltip-x": `${tooltip.x}px`,
640
+ "--tooltip-y": `${tooltip.y}px`,
641
+ } as CSSProperties;
642
+
643
+ return <div className="app-global-tooltip" role="tooltip" style={style}>{tooltip.text}</div>;
644
+ }
645
+
597
646
  function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
598
647
  event.stopPropagation();
599
648
  if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
600
649
  return;
601
650
  }
602
651
 
652
+ event.preventDefault();
653
+ if (document.documentElement.dataset.desktopShell === "macos") {
654
+ window.location.assign(href);
655
+ return;
656
+ }
657
+
603
658
  const opened = window.open(href, "_blank", "noopener,noreferrer");
604
659
  if (opened !== null) {
605
- event.preventDefault();
606
660
  opened.opener = null;
661
+ return;
607
662
  }
663
+
664
+ window.location.assign(href);
608
665
  }
609
666
 
610
667
  export function InstallationSurface(): ReactElement {
@@ -303,15 +303,15 @@ export const text = {
303
303
  } as const;
304
304
 
305
305
  export const DEFAULT_ACCENT_HUE = 123;
306
- export const DEFAULT_FONT: FontPreference = "Menlo";
307
- export const DEFAULT_FONT_SIZE: FontSizePreference = "m";
306
+ export const DEFAULT_FONT: FontPreference = "Inter";
307
+ export const DEFAULT_FONT_SIZE: FontSizePreference = "xs";
308
308
 
309
309
  export const fontOptions: FontOption[] = [
310
310
  { value: "IBM Plex Mono", label: "IBM Plex Mono", fontFamily: '"IBM Plex Mono", Menlo, Monaco, Consolas, monospace' },
311
311
  { value: "IBM Plex Sans", label: "IBM Plex Sans", fontFamily: '"IBM Plex Sans", Inter, system-ui, sans-serif' },
312
312
  { value: "IBM Plex Serif", label: "IBM Plex Serif", fontFamily: '"IBM Plex Serif", Georgia, serif' },
313
- { value: "Inter", label: "Inter", fontFamily: 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' },
314
- { value: "Menlo", label: "Menlo (default)", fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' },
313
+ { value: "Inter", label: "Inter (default)", fontFamily: 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' },
314
+ { value: "Menlo", label: "Menlo", fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' },
315
315
  { value: "Playpen Sans", label: "Playpen Sans", fontFamily: '"Playpen Sans", "Comic Sans MS", Inter, system-ui, sans-serif' },
316
316
  { value: "Poppins", label: "Poppins", fontFamily: 'Poppins, Inter, system-ui, sans-serif' },
317
317
  { value: "Source Sans", label: "Source Sans", fontFamily: '"Source Sans 3", "Source Sans Pro", "Source Sans", Inter, system-ui, sans-serif' },
@@ -543,11 +543,11 @@ export function getSystemTheme(): "light" | "dark" {
543
543
  }
544
544
 
545
545
  export function fontFamilyForPreference(font: FontPreference): string {
546
- return fontOptions.find((option) => option.value === font)?.fontFamily ?? fontOptions.find((option) => option.value === DEFAULT_FONT)?.fontFamily ?? "Menlo, monospace";
546
+ return fontOptions.find((option) => option.value === font)?.fontFamily ?? fontOptions.find((option) => option.value === DEFAULT_FONT)?.fontFamily ?? "Inter, system-ui, sans-serif";
547
547
  }
548
548
 
549
549
  export function fontSizeForPreference(size: FontSizePreference): string {
550
- return fontSizeOptions.find((option) => option.value === size)?.cssSize ?? fontSizeOptions.find((option) => option.value === DEFAULT_FONT_SIZE)?.cssSize ?? "16px";
550
+ return fontSizeOptions.find((option) => option.value === size)?.cssSize ?? fontSizeOptions.find((option) => option.value === DEFAULT_FONT_SIZE)?.cssSize ?? "14px";
551
551
  }
552
552
 
553
553
  export function isFontPreference(value: string | null): value is FontPreference {
@@ -55,7 +55,7 @@ export function renderDashboardHtml(status: GuiResponseEnvelope<GuiStatusData>):
55
55
  `<h1>aidevops app interface</h1>`,
56
56
  `<p>Made for creators.</p>`,
57
57
  `<p>AI-assisted development workflows, code quality, and deployment automation.</p>`,
58
- `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter, Menlo (default), Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
58
+ `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter (default), Menlo, Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
59
59
  `<p>Secret references stay read-only and redacted.</p>`,
60
60
  `<p>Vault surfaces show padlock indicators. Encrypted by aidevops Vault; contents visible only when unlocked through app or authorised vault commands.</p>`,
61
61
  `<p>${escapeHtml(status.data.update.message)}</p>`,
@@ -0,0 +1,49 @@
1
+ import { type Dispatch, type SetStateAction, useEffect } from "react";
2
+ import type { SurfaceId } from "./app-model";
3
+
4
+ export interface NavigationHistory {
5
+ entries: SurfaceId[];
6
+ index: number;
7
+ }
8
+
9
+ export function nextHistoryIndex(current: NavigationHistory, direction: -1 | 1): number {
10
+ return Math.min(Math.max(0, current.entries.length - 1), Math.max(0, current.index + direction));
11
+ }
12
+
13
+ export function useNavigationHistoryKeyboard(setNavigation: Dispatch<SetStateAction<NavigationHistory>>): void {
14
+ useEffect(() => {
15
+ const navigateHistoryWithKeyboard = (event: globalThis.KeyboardEvent) => {
16
+ if (shouldIgnoreHistoryShortcut(event)) {
17
+ return;
18
+ }
19
+
20
+ const direction = historyShortcutDirection(event.key);
21
+ if (direction === null) {
22
+ return;
23
+ }
24
+
25
+ event.preventDefault();
26
+ setNavigation((current) => ({ ...current, index: nextHistoryIndex(current, direction) }));
27
+ };
28
+
29
+ window.addEventListener("keydown", navigateHistoryWithKeyboard);
30
+
31
+ return () => window.removeEventListener("keydown", navigateHistoryWithKeyboard);
32
+ }, [setNavigation]);
33
+ }
34
+
35
+ function shouldIgnoreHistoryShortcut(event: globalThis.KeyboardEvent): boolean {
36
+ return !(event.metaKey || event.ctrlKey) || isEditableKeyboardTarget(event.target) || isEditableKeyboardTarget(document.activeElement);
37
+ }
38
+
39
+ function historyShortcutDirection(key: string): -1 | 1 | null {
40
+ return key === "[" ? -1 : key === "]" ? 1 : null;
41
+ }
42
+
43
+ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
44
+ if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) {
45
+ return false;
46
+ }
47
+
48
+ return target.isContentEditable || target.closest("input, textarea, select, [contenteditable], [role='textbox']") !== null;
49
+ }
@@ -10,8 +10,8 @@
10
10
  --border-hover: color-mix(in srgb, rgb(0 0 0 / 24%) 64%, rgb(255 255 255 / 48%));
11
11
  --code-bg: hsl(var(--accent-hue) 55% 91%);
12
12
  --desktop-titlebar-height: 0px;
13
- --font-family-app: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
14
- --font-size-app: 16px;
13
+ --font-family-app: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ --font-size-app: 14px;
15
15
  --glass-panel-shadow: inset 0 1px 0 rgb(255 255 255 / 58%), inset 0 -1px 0 rgb(0 0 0 / 3%), var(--shadow-soft);
16
16
  --notification-error-bg: rgb(207 34 46 / 10%);
17
17
  --notification-error-fg: #cf222e;
@@ -119,7 +119,7 @@ code {
119
119
  .app-shell {
120
120
  display: grid;
121
121
  column-gap: 8px;
122
- grid-template-columns: 72px var(--sidebar-width, 302px) 8px minmax(0, 1fr);
122
+ grid-template-columns: 72px var(--sidebar-width, 302px) 6px minmax(0, 1fr);
123
123
  grid-template-rows: minmax(0, 1fr) auto;
124
124
  height: 100vh;
125
125
  padding: calc(var(--shell-gutter) + var(--desktop-titlebar-height)) var(--shell-gutter) var(--shell-gutter);
@@ -127,7 +127,7 @@ code {
127
127
  }
128
128
 
129
129
  .app-shell.machine-rail-collapsed {
130
- grid-template-columns: var(--sidebar-width, 302px) 8px minmax(0, 1fr);
130
+ grid-template-columns: var(--sidebar-width, 302px) 6px minmax(0, 1fr);
131
131
  }
132
132
 
133
133
  .desktop-titlebar-tagline {
@@ -296,10 +296,19 @@ code {
296
296
  }
297
297
 
298
298
  .brand-lockup {
299
+ background: transparent;
300
+ border: 0;
301
+ color: var(--text-primary);
299
302
  gap: 12px;
300
303
  padding: 2px 4px;
301
304
  }
302
305
 
306
+ .brand-lockup:hover,
307
+ .brand-lockup:focus-visible {
308
+ color: var(--accent);
309
+ outline: none;
310
+ }
311
+
303
312
  .sidebar-titlebar {
304
313
  gap: 10px;
305
314
  justify-content: space-between;
@@ -819,28 +828,32 @@ code {
819
828
  }
820
829
 
821
830
  .range-labels {
831
+ border: 0;
822
832
  color: var(--text-muted);
823
833
  display: grid;
824
834
  font-size: 0.7rem;
825
835
  font-weight: 900;
826
836
  grid-template-columns: repeat(5, 1fr);
837
+ margin: 0;
838
+ padding: 0;
827
839
  text-transform: uppercase;
828
840
  }
829
841
 
830
- .range-labels span {
842
+ .range-labels button {
843
+ background: transparent;
844
+ border: 0;
845
+ border-radius: 8px;
846
+ color: inherit;
847
+ font: inherit;
848
+ padding: 3px 0;
831
849
  text-align: center;
832
850
  }
833
851
 
834
- .range-labels span:first-child {
835
- text-align: left;
836
- }
837
-
838
- .range-labels span:last-child {
839
- text-align: right;
840
- }
841
-
842
- .range-labels .active {
852
+ .range-labels button:hover,
853
+ .range-labels button:focus-visible,
854
+ .range-labels button.active {
843
855
  color: var(--accent);
856
+ outline: none;
844
857
  }
845
858
 
846
859
  .font-control select {
@@ -2206,7 +2219,7 @@ code {
2206
2219
  align-items: center;
2207
2220
  color: var(--text-secondary);
2208
2221
  display: inline-flex;
2209
- font-size: 0.95em;
2222
+ font-size: 1em;
2210
2223
  font-weight: 800;
2211
2224
  gap: 4px;
2212
2225
  max-width: 100%;
@@ -2545,6 +2558,32 @@ button.managed-toggle:focus-visible {
2545
2558
  transform: translate(-50%, 0);
2546
2559
  }
2547
2560
 
2561
+ .apps-surface [data-tooltip]::after {
2562
+ content: none;
2563
+ }
2564
+
2565
+ .app-global-tooltip {
2566
+ background: color-mix(in srgb, var(--surface-elevated) 94%, black);
2567
+ border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border));
2568
+ border-radius: 10px;
2569
+ box-shadow: 0 18px 46px rgb(0 0 0 / 38%);
2570
+ color: var(--text-primary);
2571
+ font-size: 0.72rem;
2572
+ font-weight: 700;
2573
+ left: clamp(12px, var(--tooltip-x), calc(100vw - 12px));
2574
+ line-height: 1.35;
2575
+ max-width: min(440px, 72vw);
2576
+ overflow-wrap: anywhere;
2577
+ padding: 8px 10px;
2578
+ pointer-events: none;
2579
+ position: fixed;
2580
+ text-align: left;
2581
+ top: var(--tooltip-y);
2582
+ transform: translate(-50%, calc(-100% - 10px));
2583
+ width: max-content;
2584
+ z-index: 10000;
2585
+ }
2586
+
2548
2587
  .data-row,
2549
2588
  .editable-row {
2550
2589
  display: grid;
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.29.18
15
+ # Version: 3.29.20
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)