aidevops 3.29.21 → 3.29.23

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.21-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.23-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.21
1
+ 3.29.23
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.21
8
+ # Version: 3.29.23
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.21",
3
+ "version": "3.29.23",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -152,7 +152,6 @@ function binaryPathCandidates(binary: string): string[] {
152
152
  }
153
153
 
154
154
  function expandedPath(): string {
155
- const home = process.env.HOME ?? "";
156
155
  const extraPaths = [
157
156
  "/opt/homebrew/bin",
158
157
  "/usr/local/bin",
@@ -160,11 +159,16 @@ function expandedPath(): string {
160
159
  "/bin",
161
160
  "/usr/sbin",
162
161
  "/sbin",
163
- join(home, ".bun/bin"),
164
- join(home, ".local/bin"),
165
- join(home, ".cursor/bin"),
166
- join(home, ".qlty/bin"),
167
162
  ];
163
+ const home = process.env.HOME;
164
+ if (home) {
165
+ extraPaths.push(
166
+ join(home, ".bun/bin"),
167
+ join(home, ".local/bin"),
168
+ join(home, ".cursor/bin"),
169
+ join(home, ".qlty/bin"),
170
+ );
171
+ }
168
172
  return [...new Set([...(process.env.PATH ?? "").split(":"), ...extraPaths].filter(Boolean))].join(":");
169
173
  }
170
174
 
@@ -382,20 +382,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
382
382
  }
383
383
 
384
384
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
385
- guard message.name == "accentHue" else {
385
+ switch message.name {
386
+ case "accentHue":
387
+ updateAccentHueFromMessage(message.body)
388
+ case "externalLink":
389
+ openExternalLinkFromMessage(message.body)
390
+ default:
386
391
  return
387
392
  }
393
+ }
388
394
 
389
- if let number = message.body as? NSNumber {
395
+ private func updateAccentHueFromMessage(_ body: Any) {
396
+ if let number = body as? NSNumber {
390
397
  updateTitlebarAccent(hue: CGFloat(number.doubleValue))
391
398
  return
392
399
  }
393
400
 
394
- if let value = message.body as? String, let hue = Double(value) {
401
+ if let value = body as? String, let hue = Double(value) {
395
402
  updateTitlebarAccent(hue: CGFloat(hue))
396
403
  }
397
404
  }
398
405
 
406
+ private func openExternalLinkFromMessage(_ body: Any) {
407
+ guard let value = body as? String, let url = URL(string: value) else {
408
+ return
409
+ }
410
+
411
+ _ = openExternalURL(url)
412
+ }
413
+
399
414
  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
400
415
  guard let url = navigationAction.request.url else {
401
416
  decisionHandler(.allow)
@@ -407,8 +422,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
407
422
  return
408
423
  }
409
424
 
410
- if url.scheme == "http" || url.scheme == "https" {
411
- NSWorkspace.shared.open(url)
425
+ if openExternalURL(url) {
412
426
  decisionHandler(.cancel)
413
427
  return
414
428
  }
@@ -421,13 +435,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
421
435
  return nil
422
436
  }
423
437
 
424
- if url.scheme == "http" || url.scheme == "https" {
425
- NSWorkspace.shared.open(url)
426
- }
438
+ _ = openExternalURL(url)
427
439
 
428
440
  return nil
429
441
  }
430
442
 
443
+ private func openExternalURL(_ url: URL) -> Bool {
444
+ let scheme = url.scheme?.lowercased()
445
+ guard scheme == "http" || scheme == "https" else {
446
+ return false
447
+ }
448
+
449
+ NSWorkspace.shared.open(url)
450
+ return true
451
+ }
452
+
431
453
  private func isLocalAppURL(_ url: URL) -> Bool {
432
454
  let host = url.host ?? ""
433
455
  return (host == "127.0.0.1" || host == "localhost") && url.port == Int(webPort)
@@ -692,6 +714,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
692
714
  configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
693
715
  let userContentController = WKUserContentController()
694
716
  userContentController.add(self, name: "accentHue")
717
+ userContentController.add(self, name: "externalLink")
695
718
  configuration.userContentController = userContentController
696
719
  webView = WKWebView(frame: .zero, configuration: configuration)
697
720
  webView.navigationDelegate = self
@@ -97,6 +97,7 @@ function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificat
97
97
  const appNeedsUpdate = (input.aiApps ?? []).filter((app) => app.needs_update);
98
98
  const authErrors = (input.oauthPool?.providers ?? []).filter((provider) => provider.auth_errors > 0);
99
99
  const rateLimited = (input.oauthPool?.providers ?? []).filter((provider) => provider.rate_limited > 0);
100
+ const oauthPoolSourceRef = input.oauthPool?.path_ref ?? "oauth_pool";
100
101
  const notifications: GuiNotificationSummary[] = [];
101
102
 
102
103
  if (input.restartRequired) {
@@ -135,7 +136,7 @@ function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificat
135
136
  severity: "error",
136
137
  category: "runtime",
137
138
  source: "gui-status",
138
- source_ref: input.oauthPool.path_ref,
139
+ source_ref: oauthPoolSourceRef,
139
140
  status: "active",
140
141
  actions: [surfaceAction("open-ai-providers", "Open AI providers", "aiProviders"), commandAction("check-oauth-pool", "Check provider pool", `aidevops oauth-pool check ${provider.provider}`)],
141
142
  }));
@@ -149,7 +150,7 @@ function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificat
149
150
  severity: "warning",
150
151
  category: "runtime",
151
152
  source: "gui-status",
152
- source_ref: input.oauthPool.path_ref,
153
+ source_ref: oauthPoolSourceRef,
153
154
  status: "active",
154
155
  actions: [surfaceAction("open-ai-providers", "Open AI providers", "aiProviders")],
155
156
  }));
@@ -2,8 +2,8 @@ import type { GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
2
  import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, useEffect, useState } from "react";
3
3
  import { MachineRail, Sidebar } from "./AppNavigation";
4
4
  import { Workspace } from "./AppWorkspace";
5
- import type { ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
6
- import { DEFAULT_ACCENT_HUE, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isFontPreference, isFontSizePreference } from "./app-model";
5
+ import type { ContrastPreference, ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
6
+ import { DEFAULT_ACCENT_HUE, DEFAULT_CONTRAST, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isContrastPreference, isFontPreference, isFontSizePreference } from "./app-model";
7
7
  import { type NavigationHistory, nextHistoryIndex, useNavigationHistoryKeyboard } from "./navigation-history";
8
8
  import { fetchStatus, mockedStatus } from "./status-client";
9
9
 
@@ -23,6 +23,7 @@ type AppearanceStorageKey = (typeof appearanceStorageKeys)[keyof typeof appearan
23
23
 
24
24
  export const appearanceStorageKeys = {
25
25
  accentHue: "aidevops-gui-accent-hue",
26
+ contrast: "aidevops-gui-contrast",
26
27
  font: "aidevops-gui-font",
27
28
  fontSize: "aidevops-gui-font-size",
28
29
  machineRail: "aidevops-gui-show-machine-rail",
@@ -37,9 +38,27 @@ const minSidebarWidth = 300;
37
38
  const maxSidebarWidth = 520;
38
39
  export const loadingSkeletonPanelLabels = ["machine rail", "sidebar", "workspace", "status bar"] as const;
39
40
  export const loadingBrandGlyph = ">_";
41
+ const loadingMachineOrbKeys = ["machine-orb-a", "machine-orb-b", "machine-orb-c", "machine-orb-d", "machine-orb-e", "machine-orb-f"] as const;
42
+ const loadingListRows = [
43
+ { key: "loading-list-a", variant: "short" },
44
+ { key: "loading-list-b", variant: "line" },
45
+ { key: "loading-list-c", variant: "line" },
46
+ { key: "loading-list-d", variant: "short" },
47
+ { key: "loading-list-e", variant: "line" },
48
+ { key: "loading-list-f", variant: "line" },
49
+ { key: "loading-list-g", variant: "short" },
50
+ { key: "loading-list-h", variant: "line" },
51
+ { key: "loading-list-i", variant: "line" },
52
+ { key: "loading-list-j", variant: "short" },
53
+ { key: "loading-list-k", variant: "line" },
54
+ { key: "loading-list-l", variant: "line" },
55
+ ] as const;
56
+ const loadingCardKeys = ["loading-card-a", "loading-card-b", "loading-card-c", "loading-card-d", "loading-card-e", "loading-card-f"] as const;
57
+ const loadingStatusRows = ["dot", "line-a", "line-b", "line-c", "line-d", "line-e", "line-f", "line-g"] as const;
40
58
 
41
59
  export interface StoredAppearancePreferences {
42
60
  accentHue: number;
61
+ contrastPreference: ContrastPreference;
43
62
  fontPreference: FontPreference;
44
63
  fontSizePreference: FontSizePreference;
45
64
  machineRailVisible: boolean;
@@ -51,11 +70,13 @@ export interface StoredAppearancePreferences {
51
70
  export function readStoredAppearancePreferences(storage: ReadableAppearanceStorage | undefined = browserLocalStorage(), isDesktopShell = isMacosDesktopBrowser()): StoredAppearancePreferences {
52
71
  const savedTheme = storage?.getItem(appearanceStorageKeys.theme);
53
72
  const savedAccentHue = Number.parseInt(storage?.getItem(appearanceStorageKeys.accentHue) ?? "", 10);
73
+ const savedContrast = storage?.getItem(appearanceStorageKeys.contrast) ?? null;
54
74
  const savedFont = storage?.getItem(appearanceStorageKeys.font) ?? null;
55
75
  const savedFontSize = storage?.getItem(appearanceStorageKeys.fontSize) ?? null;
56
76
 
57
77
  return {
58
78
  accentHue: Number.isFinite(savedAccentHue) && savedAccentHue >= 0 && savedAccentHue <= 359 ? savedAccentHue : DEFAULT_ACCENT_HUE,
79
+ contrastPreference: isContrastPreference(savedContrast) ? savedContrast : DEFAULT_CONTRAST,
59
80
  fontPreference: isFontPreference(savedFont) ? savedFont : DEFAULT_FONT,
60
81
  fontSizePreference: isFontSizePreference(savedFontSize) ? savedFontSize : DEFAULT_FONT_SIZE,
61
82
  machineRailVisible: isDesktopShell ? readStoredBoolean(storage, appearanceStorageKeys.machineRail, true) : true,
@@ -72,6 +93,7 @@ export function App(): ReactElement {
72
93
  const [navigation, setNavigation] = useState<NavigationHistory>({ entries: ["overview"], index: 0 });
73
94
  const [themePreference, setThemePreference] = useState<ThemePreference>(storedAppearancePreferences.themePreference);
74
95
  const [accentHue, setAccentHue] = useState(storedAppearancePreferences.accentHue);
96
+ const [contrastPreference, setContrastPreference] = useState<ContrastPreference>(storedAppearancePreferences.contrastPreference);
75
97
  const [fontPreference, setFontPreference] = useState<FontPreference>(storedAppearancePreferences.fontPreference);
76
98
  const [fontSizePreference, setFontSizePreference] = useState<FontSizePreference>(storedAppearancePreferences.fontSizePreference);
77
99
  const [machineRailVisible, setMachineRailVisible] = useState(storedAppearancePreferences.machineRailVisible);
@@ -146,6 +168,11 @@ export function App(): ReactElement {
146
168
  sendNativeAccentHue(accentHue);
147
169
  }, [accentHue]);
148
170
 
171
+ useEffect(() => {
172
+ document.documentElement.dataset.contrast = contrastPreference;
173
+ persistAppearancePreference(appearanceStorageKeys.contrast, contrastPreference);
174
+ }, [contrastPreference]);
175
+
149
176
  useEffect(() => {
150
177
  document.documentElement.style.setProperty("--font-family-app", fontFamilyForPreference(fontPreference));
151
178
  persistAppearancePreference(appearanceStorageKeys.font, fontPreference);
@@ -191,12 +218,14 @@ export function App(): ReactElement {
191
218
  <Sidebar
192
219
  activeSurface={activeSurface}
193
220
  accentHue={accentHue}
221
+ contrastPreference={contrastPreference}
194
222
  conversationMode={conversationMode}
195
223
  fontSizePreference={fontSizePreference}
196
224
  fontPreference={fontPreference}
197
225
  selectedLocalRepoIndex={selectedLocalRepoIndex}
198
226
  selectedSessionId={selectedSessionId}
199
227
  setAccentHue={setAccentHue}
228
+ setContrastPreference={setContrastPreference}
200
229
  setActiveSurface={setActiveSurface}
201
230
  setConversationMode={setConversationMode}
202
231
  setFontSizePreference={setFontSizePreference}
@@ -246,7 +275,7 @@ export function AppLoadingSkeleton({ machineRailVisible }: { machineRailVisible:
246
275
  <LoadingBrandOverlay />
247
276
  {machineRailVisible ? (
248
277
  <section className="machine-rail loading-panel" aria-label={loadingSkeletonPanelLabels[0]}>
249
- {Array.from({ length: 6 }, (_, index) => <span className="loading-orb" key={index} />)}
278
+ {loadingMachineOrbKeys.map((key) => <span className="loading-orb" key={key} />)}
250
279
  </section>
251
280
  ) : null}
252
281
  <section className="app-sidebar loading-panel" aria-label={loadingSkeletonPanelLabels[1]}>
@@ -256,7 +285,7 @@ export function AppLoadingSkeleton({ machineRailVisible }: { machineRailVisible:
256
285
  </div>
257
286
  <span className="loading-pill" />
258
287
  <div className="loading-list">
259
- {Array.from({ length: 12 }, (_, index) => <span className={index % 3 === 0 ? "loading-line loading-line-short" : "loading-line"} key={index} />)}
288
+ {loadingListRows.map((row) => <span className={row.variant === "short" ? "loading-line loading-line-short" : "loading-line"} key={row.key} />)}
260
289
  </div>
261
290
  <div className="loading-sidebar-footer">
262
291
  <span className="loading-pill" />
@@ -275,13 +304,13 @@ export function AppLoadingSkeleton({ machineRailVisible }: { machineRailVisible:
275
304
  <div className="loading-workspace-body">
276
305
  <span className="loading-line loading-line-hero" />
277
306
  <div className="loading-card-grid">
278
- {Array.from({ length: 6 }, (_, index) => <span className="loading-card" key={index} />)}
307
+ {loadingCardKeys.map((key) => <span className="loading-card" key={key} />)}
279
308
  </div>
280
309
  <span className="loading-panel-block" />
281
310
  </div>
282
311
  </section>
283
- <div className="desktop-status-bar loading-status-bar" aria-label={loadingSkeletonPanelLabels[3]}>
284
- {Array.from({ length: 8 }, (_, index) => <span className={index === 0 ? "loading-dot" : "loading-line"} key={index} />)}
312
+ <div className="desktop-status-bar loading-status-bar" aria-label={loadingSkeletonPanelLabels[3]} role="status">
313
+ {loadingStatusRows.map((row) => <span className={row === "dot" ? "loading-dot" : "loading-line"} key={row} />)}
285
314
  </div>
286
315
  </main>
287
316
  );
@@ -289,7 +318,7 @@ export function AppLoadingSkeleton({ machineRailVisible }: { machineRailVisible:
289
318
 
290
319
  function LoadingBrandOverlay(): ReactElement {
291
320
  return (
292
- <div className="loading-brand-overlay" aria-label="Starting aidevops">
321
+ <div className="loading-brand-overlay" aria-label="Starting aidevops" role="status">
293
322
  <span className="loading-brand-mark" aria-hidden="true">
294
323
  <span className="loading-brand-word">aidevops</span>
295
324
  <span className="loading-brand-chevron">&gt;</span>
@@ -0,0 +1,81 @@
1
+ import type { GuiAppActionJobSummary } from "@aidevops/gui-shared";
2
+ import { type ReactElement, type ReactNode, useEffect, useRef } from "react";
3
+ import { FiX } from "react-icons/fi";
4
+
5
+ const ANSI_PATTERN = /\x1b\[([0-9;]*)m/g;
6
+
7
+ export function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; onDismiss: (jobId: string) => void }): ReactElement {
8
+ const outputRef = useRef<HTMLPreElement | null>(null);
9
+
10
+ useEffect(() => {
11
+ if (outputRef.current !== null) {
12
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
13
+ }
14
+ }, [job.output.length]);
15
+
16
+ return (
17
+ <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
18
+ <header><strong>{job.app_id} {job.action}</strong><span>{terminalStatusLabel(job)}</span><button aria-label={`Dismiss ${job.app_id} ${job.action} terminal output`} className="terminal-close-button" onClick={() => onDismiss(job.id)} title="Dismiss terminal output" type="button"><FiX aria-hidden="true" /></button></header>
19
+ <pre ref={outputRef}>{renderTerminalOutput(job.output)}</pre>
20
+ </section>
21
+ );
22
+ }
23
+
24
+ function terminalStatusLabel(job: GuiAppActionJobSummary): string {
25
+ if (job.status === "running") {
26
+ return "running";
27
+ }
28
+ if (job.exit_code === null) {
29
+ return job.status;
30
+ }
31
+ return `${job.status} · exit ${job.exit_code}`;
32
+ }
33
+
34
+ function renderTerminalOutput(lines: string[]): ReactNode[] {
35
+ return lines.flatMap((line, index) => [
36
+ ...renderAnsiLine(line, `line-${index}`),
37
+ index === lines.length - 1 ? null : "\n",
38
+ ]).filter((node): node is ReactNode => node !== null);
39
+ }
40
+
41
+ function renderAnsiLine(line: string, keyPrefix: string): ReactNode[] {
42
+ const nodes: ReactNode[] = [];
43
+ ANSI_PATTERN.lastIndex = 0;
44
+ let activeClass = "";
45
+ let lastIndex = 0;
46
+ let match = ANSI_PATTERN.exec(line);
47
+
48
+ while (match !== null) {
49
+ if (match.index > lastIndex) {
50
+ nodes.push(terminalSpan(line.slice(lastIndex, match.index), activeClass, `${keyPrefix}-${nodes.length}`));
51
+ }
52
+ activeClass = ansiClassForCodes(match[1]);
53
+ lastIndex = ANSI_PATTERN.lastIndex;
54
+ match = ANSI_PATTERN.exec(line);
55
+ }
56
+
57
+ if (lastIndex < line.length) {
58
+ nodes.push(terminalSpan(line.slice(lastIndex), activeClass, `${keyPrefix}-${nodes.length}`));
59
+ }
60
+
61
+ if (nodes.length === 0) {
62
+ nodes.push(terminalSpan(line, activeClass, `${keyPrefix}-0`));
63
+ }
64
+
65
+ return nodes;
66
+ }
67
+
68
+ function terminalSpan(textValue: string, className: string, key: string): ReactNode {
69
+ const promptClass = textValue.startsWith("$ ") ? "ansi-prompt" : "";
70
+ const combinedClass = [className, promptClass].filter(Boolean).join(" ");
71
+ return combinedClass.length > 0 ? <span className={combinedClass} key={key}>{textValue}</span> : textValue;
72
+ }
73
+
74
+ function ansiClassForCodes(rawCodes: string): string {
75
+ const codes = rawCodes.length === 0 ? [0] : rawCodes.split(";").map((code) => Number.parseInt(code, 10));
76
+ const colorCode = [...codes].reverse().find((code) => (code >= 30 && code <= 37) || (code >= 90 && code <= 97));
77
+ if (colorCode === undefined) {
78
+ return "";
79
+ }
80
+ return `ansi-fg-${colorCode}`;
81
+ }
@@ -7,10 +7,8 @@ import {
7
7
  FiBox,
8
8
  FiBriefcase,
9
9
  FiCalendar,
10
- FiChevronDown,
11
10
  FiChevronLeft,
12
11
  FiChevronRight,
13
- FiChevronUp,
14
12
  FiClock,
15
13
  FiDownloadCloud,
16
14
  FiFileText,
@@ -29,17 +27,19 @@ import {
29
27
  FiMessageSquare,
30
28
  FiMonitor,
31
29
  FiPackage,
32
- FiRotateCcw,
33
30
  FiServer,
34
31
  FiSettings,
35
32
  FiShield,
36
33
  FiTerminal,
37
34
  FiUsers,
38
35
  } from "react-icons/fi";
39
- import type { ConversationMode, FontPreference, FontSizePreference, ShellMode, SidebarMode, SurfaceIconName, SurfaceId, SurfaceNavGroup, SurfaceNavItem, ThemePreference } from "./app-model";
40
- import { DEFAULT_ACCENT_HUE, dashboardNavItem, fontFamilyForPreference, fontOptions, fontSizeOptions, navGroups, sidebarModeForSurface, surfaceRecordCounts, text } from "./app-model";
36
+ import type { ContrastPreference, ConversationMode, FontPreference, FontSizePreference, ShellMode, SidebarMode, SurfaceIconName, SurfaceId, SurfaceNavGroup, SurfaceNavItem, ThemePreference } from "./app-model";
37
+ import { dashboardNavItem, navGroups, sidebarModeForSurface, surfaceRecordCounts, text } from "./app-model";
38
+ import { SidebarFooter } from "./AppearanceControls";
41
39
  import { VaultPadlock, vaultCollectionForSurface } from "./VaultBadges";
42
40
 
41
+ export { hueFromInputValue } from "./AppearanceControls";
42
+
43
43
  const surfaceIcons: Record<SurfaceIconName, IconType> = {
44
44
  apps: FiBox,
45
45
  bookmark: FiBookmark,
@@ -71,22 +71,6 @@ const surfaceIcons: Record<SurfaceIconName, IconType> = {
71
71
  users: FiUsers,
72
72
  };
73
73
 
74
- export function hueFromInputValue(value: string): number | null {
75
- const trimmedValue = value.trim();
76
-
77
- if (trimmedValue.length === 0) {
78
- return null;
79
- }
80
-
81
- const hue = Number(trimmedValue);
82
-
83
- if (!Number.isInteger(hue)) {
84
- return null;
85
- }
86
-
87
- return Math.min(359, Math.max(0, hue));
88
- }
89
-
90
74
  export function SurfaceGlyph({ icon }: { icon: SurfaceIconName }) {
91
75
  const Icon = surfaceIcons[icon];
92
76
 
@@ -110,9 +94,10 @@ export function MachineRail({ machine }: { machine?: GuiMachineSummary }) {
110
94
  );
111
95
  }
112
96
 
113
- export function Sidebar({ activeSurface, accentHue, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, selectedSessionId, setAccentHue, setActiveSurface, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
97
+ export function Sidebar({ activeSurface, accentHue, contrastPreference, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, selectedSessionId, setAccentHue, setActiveSurface, setContrastPreference, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
114
98
  activeSurface: SurfaceId;
115
99
  accentHue: number;
100
+ contrastPreference: ContrastPreference;
116
101
  conversationMode: ConversationMode;
117
102
  fontPreference: FontPreference;
118
103
  fontSizePreference: FontSizePreference;
@@ -120,6 +105,7 @@ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPrefer
120
105
  selectedSessionId: string | undefined;
121
106
  setAccentHue: (hue: number) => void;
122
107
  setActiveSurface: (surface: SurfaceId) => void;
108
+ setContrastPreference: (contrast: ContrastPreference) => void;
123
109
  setConversationMode: (mode: ConversationMode) => void;
124
110
  setFontPreference: (font: FontPreference) => void;
125
111
  setFontSizePreference: (size: FontSizePreference) => void;
@@ -165,9 +151,11 @@ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPrefer
165
151
  </nav>
166
152
  <SidebarFooter
167
153
  accentHue={accentHue}
154
+ contrastPreference={contrastPreference}
168
155
  fontPreference={fontPreference}
169
156
  fontSizePreference={fontSizePreference}
170
157
  setAccentHue={setAccentHue}
158
+ setContrastPreference={setContrastPreference}
171
159
  setFontPreference={setFontPreference}
172
160
  setFontSizePreference={setFontSizePreference}
173
161
  setShowBorders={setShowBorders}
@@ -365,7 +353,7 @@ function SidebarHeader({ setActiveSurface, setShellMode, shellMode }: {
365
353
  return (
366
354
  <header className="sidebar-header">
367
355
  <div className="sidebar-titlebar">
368
- <button className="brand-lockup" onClick={() => setActiveSurface(dashboardNavItem.id)} title="Return to dashboard" type="button">
356
+ <button className="brand-lockup" onClick={() => { setShellMode("devices"); setActiveSurface(dashboardNavItem.id); }} title="Return to dashboard" type="button">
369
357
  <span className="terminal-mark" aria-hidden="true">›_</span>
370
358
  <strong>{text.aidevops}</strong>
371
359
  </button>
@@ -381,169 +369,3 @@ function SidebarHeader({ setActiveSurface, setShellMode, shellMode }: {
381
369
  </header>
382
370
  );
383
371
  }
384
-
385
- function SidebarFooter({ accentHue, fontPreference, fontSizePreference, setAccentHue, setFontPreference, setFontSizePreference, setShowBorders, setShowNavCounts, setThemePreference, showBorders, showNavCounts, themePreference }: {
386
- accentHue: number;
387
- fontPreference: FontPreference;
388
- fontSizePreference: FontSizePreference;
389
- setAccentHue: (hue: number) => void;
390
- setFontPreference: (font: FontPreference) => void;
391
- setFontSizePreference: (size: FontSizePreference) => void;
392
- setShowBorders: (show: boolean) => void;
393
- setShowNavCounts: (show: boolean) => void;
394
- setThemePreference: (theme: ThemePreference) => void;
395
- showBorders: boolean;
396
- showNavCounts: boolean;
397
- themePreference: ThemePreference;
398
- }) {
399
- const [appearanceOpen, setAppearanceOpen] = useState(true);
400
- const AppearanceChevron = appearanceOpen ? FiChevronDown : FiChevronUp;
401
- const selectedFontFamily = fontFamilyForPreference(fontPreference);
402
- const fontIndex = Math.max(0, fontOptions.findIndex((option) => option.value === fontPreference));
403
- const fontSizeIndex = Math.max(0, fontSizeOptions.findIndex((option) => option.value === fontSizePreference));
404
- const canSelectPreviousFont = fontIndex > 0;
405
- const canSelectNextFont = fontIndex < fontOptions.length - 1;
406
- const [hueInput, setHueInput] = useState(() => String(accentHue));
407
- const setFontByIndex = (index: number) => {
408
- const nextFont = fontOptions[index]?.value;
409
-
410
- if (nextFont === undefined) {
411
- return;
412
- }
413
-
414
- setFontPreference(nextFont);
415
- };
416
- const updateAccentHue = (value: number) => {
417
- if (!Number.isFinite(value)) {
418
- return;
419
- }
420
- setAccentHue(Math.min(359, Math.max(0, value)));
421
- };
422
- const updateHueInput = (value: string) => {
423
- setHueInput(value);
424
-
425
- const nextHue = hueFromInputValue(value);
426
-
427
- if (nextHue === null) {
428
- return;
429
- }
430
-
431
- updateAccentHue(nextHue);
432
- };
433
-
434
- useEffect(() => {
435
- setHueInput(String(accentHue));
436
- }, [accentHue]);
437
-
438
- return (
439
- <footer className="sidebar-footer">
440
- <section className={appearanceOpen ? "appearance-panel open" : "appearance-panel collapsed"}>
441
- <button
442
- aria-expanded={appearanceOpen}
443
- className="appearance-panel-tab"
444
- onClick={() => setAppearanceOpen((current) => !current)}
445
- type="button"
446
- >
447
- {text.appearance}
448
- <AppearanceChevron aria-hidden="true" className="appearance-chevron" />
449
- </button>
450
- {appearanceOpen ? <div className="appearance-panel-body">
451
- <fieldset className="theme-control compact" aria-label={text.theme}>
452
- {(["system", "light", "dark"] as const).map((theme) => (
453
- <button
454
- aria-pressed={themePreference === theme}
455
- className={themePreference === theme ? "theme-option active" : "theme-option"}
456
- key={theme}
457
- onClick={() => setThemePreference(theme)}
458
- type="button"
459
- >
460
- {theme}
461
- </button>
462
- ))}
463
- </fieldset>
464
- <div className="theme-hue-control">
465
- <div className="theme-control-heading">
466
- <div className="hue-label-row">
467
- <label htmlFor="theme-hue-slider">{text.hue}</label>
468
- <input
469
- aria-label="Hue value"
470
- className="hue-number-input"
471
- max="359"
472
- min="0"
473
- onChange={(event) => updateHueInput(event.currentTarget.value)}
474
- type="number"
475
- value={hueInput}
476
- />
477
- </div>
478
- <button aria-label="Reset hue to default" className="icon-reset-button" onClick={() => setAccentHue(DEFAULT_ACCENT_HUE)} title={text.reset} type="button"><FiRotateCcw aria-hidden="true" /></button>
479
- </div>
480
- <input
481
- id="theme-hue-slider"
482
- max="359"
483
- min="0"
484
- onChange={(event) => updateAccentHue(Number.parseInt(event.currentTarget.value, 10))}
485
- type="range"
486
- value={accentHue}
487
- />
488
- </div>
489
- <label className="switch-control appearance-switch">
490
- <strong>{text.showBorders}</strong>
491
- <input checked={showBorders} onChange={(event) => setShowBorders(event.currentTarget.checked)} type="checkbox" />
492
- <span aria-hidden="true" />
493
- </label>
494
- <label className="switch-control appearance-switch">
495
- <strong>{text.showCounts}</strong>
496
- <input checked={showNavCounts} onChange={(event) => setShowNavCounts(event.currentTarget.checked)} type="checkbox" />
497
- <span aria-hidden="true" />
498
- </label>
499
- <div className="font-size-control">
500
- <label htmlFor="font-size-slider">{text.fontSize}</label>
501
- <input
502
- id="font-size-slider"
503
- max={fontSizeOptions.length - 1}
504
- min="0"
505
- onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "xs")}
506
- step="1"
507
- type="range"
508
- value={fontSizeIndex}
509
- />
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>
524
- </div>
525
- <div className="font-control">
526
- <span id="appearance-font-selector-label">{text.font}</span>
527
- <div className="selector-with-stepper font-selector-row">
528
- <button aria-label="Previous font" className="selector-step-button" disabled={!canSelectPreviousFont} onClick={() => setFontByIndex(fontIndex - 1)} type="button"><FiChevronLeft aria-hidden="true" /></button>
529
- <select
530
- aria-labelledby="appearance-font-selector-label"
531
- onChange={(event) => setFontPreference(event.currentTarget.value as FontPreference)}
532
- style={{ fontFamily: selectedFontFamily }}
533
- value={fontPreference}
534
- >
535
- {fontOptions.map((option) => (
536
- <option key={option.value} style={{ fontFamily: option.fontFamily }} value={option.value}>
537
- {option.label}
538
- </option>
539
- ))}
540
- </select>
541
- <button aria-label="Next font" className="selector-step-button" disabled={!canSelectNextFont} onClick={() => setFontByIndex(fontIndex + 1)} type="button"><FiChevronRight aria-hidden="true" /></button>
542
- </div>
543
- </div>
544
- </div>
545
- : null}
546
- </section>
547
- </footer>
548
- );
549
- }