mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Merge branch 'agents/abundant-squirrel'
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
This commit is contained in:
@@ -3,12 +3,11 @@ import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranc
|
||||
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import { HotkeyRecorder, ToggleSwitch } from '@renderer/components/ui';
|
||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import { isMac } from '@renderer/lib/platform';
|
||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
@@ -1339,13 +1338,6 @@ function QuickPromptSettingsSection({
|
||||
const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
|
||||
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
|
||||
|
||||
const hotkeyKeys = hotkey
|
||||
.replace('Super', isMac ? '⌘' : 'Win')
|
||||
.replace('Alt', isMac ? '⌥' : 'Alt')
|
||||
.replace('Shift', '⇧')
|
||||
.split('+')
|
||||
.map((k) => k.trim());
|
||||
|
||||
// Group models by tier for the dropdown
|
||||
const tierOrder = ['premium', 'standard', 'fast'] as const;
|
||||
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
|
||||
@@ -1372,7 +1364,7 @@ function QuickPromptSettingsSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable / Disable + Keyboard Shortcut — compact row */}
|
||||
{/* Enable / Disable toggle */}
|
||||
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
||||
<button
|
||||
className="flex flex-1 items-start gap-0 text-left"
|
||||
@@ -1381,18 +1373,11 @@ function QuickPromptSettingsSection({
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
Global hotkey
|
||||
Enable global hotkey
|
||||
</span>
|
||||
<div className="mt-1.5 flex items-center gap-1">
|
||||
{hotkeyKeys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="rounded-[5px] border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-[3px] font-mono text-[11px] font-medium leading-none text-[var(--color-text-secondary)] shadow-sm shadow-black/20"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||
Summon Quick Prompt from any app
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
|
||||
@@ -1400,6 +1385,20 @@ function QuickPromptSettingsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hotkey Recorder */}
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Keyboard shortcut
|
||||
</span>
|
||||
</div>
|
||||
<HotkeyRecorder
|
||||
value={hotkey}
|
||||
disabled={!enabled}
|
||||
onChange={(newHotkey) => onUpdate?.({ hotkey: newHotkey })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Model — compact dropdown selector */}
|
||||
<div className="mt-6">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { isMac } from '@renderer/lib/platform';
|
||||
|
||||
/** Modifier keys we recognise, in canonical display order. */
|
||||
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
|
||||
|
||||
/** Keys that should never be accepted as the primary (non-modifier) key. */
|
||||
const IGNORED_KEYS = new Set([
|
||||
'Dead', 'Unidentified', 'Process', 'CapsLock', 'NumLock', 'ScrollLock',
|
||||
'Fn', 'FnLock', 'Hyper', 'Super', 'OS',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Convert a portable hotkey string (e.g. `"Alt+Shift+C"`) into
|
||||
* platform-aware display tokens.
|
||||
*/
|
||||
export function hotkeyToDisplayTokens(hotkey: string): string[] {
|
||||
return hotkey
|
||||
.replace(/\bSuper\b/g, isMac ? '⌘' : 'Win')
|
||||
.replace(/\bMeta\b/g, isMac ? '⌘' : 'Win')
|
||||
.replace(/\bCtrl\b/g, isMac ? '⌃' : 'Ctrl')
|
||||
.replace(/\bControl\b/g, isMac ? '⌃' : 'Ctrl')
|
||||
.replace(/\bAlt\b/g, isMac ? '⌥' : 'Alt')
|
||||
.replace(/\bShift\b/g, isMac ? '⇧' : 'Shift')
|
||||
.split('+')
|
||||
.map((k) => k.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a `KeyboardEvent` key name into the portable format used in
|
||||
* settings (Electron accelerator-compatible).
|
||||
*/
|
||||
function normaliseKeyName(key: string): string | undefined {
|
||||
if (MODIFIER_KEYS.has(key) || IGNORED_KEYS.has(key)) return undefined;
|
||||
|
||||
// Letters → uppercase
|
||||
if (/^[a-zA-Z]$/.test(key)) return key.toUpperCase();
|
||||
|
||||
// Digits
|
||||
if (/^[0-9]$/.test(key)) return key;
|
||||
|
||||
// F-keys
|
||||
if (/^F\d{1,2}$/.test(key)) return key;
|
||||
|
||||
// Named keys
|
||||
const named: Record<string, string> = {
|
||||
' ': 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape',
|
||||
Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert',
|
||||
Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown',
|
||||
ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right',
|
||||
'+': 'Plus', '-': 'Minus', '=': 'Equal',
|
||||
'[': 'BracketLeft', ']': 'BracketRight',
|
||||
'\\': 'Backslash', '/': 'Slash',
|
||||
';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period',
|
||||
'`': 'Backquote',
|
||||
};
|
||||
return named[key] ?? key;
|
||||
}
|
||||
|
||||
/** Build the portable hotkey string from modifier flags and a key name. */
|
||||
function buildHotkeyString(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }, key: string): string {
|
||||
const parts: string[] = [];
|
||||
if (mods.ctrl) parts.push('Ctrl');
|
||||
if (mods.alt) parts.push('Alt');
|
||||
if (mods.shift) parts.push('Shift');
|
||||
if (mods.meta) parts.push('Super');
|
||||
parts.push(key);
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
interface HotkeyRecorderProps {
|
||||
value: string;
|
||||
onChange: (hotkey: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function HotkeyRecorder({ value, onChange, disabled }: HotkeyRecorderProps) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [liveKeys, setLiveKeys] = useState<string | null>(null);
|
||||
const recorderRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const displayTokens = hotkeyToDisplayTokens(liveKeys ?? value);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setRecording(false);
|
||||
setLiveKeys(null);
|
||||
}, []);
|
||||
|
||||
// Escape cancels recording
|
||||
// Valid combo (modifier + key) commits immediately
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Escape cancels
|
||||
if (e.key === 'Escape') {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
const mods = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey };
|
||||
const hasModifier = mods.ctrl || mods.alt || mods.shift || mods.meta;
|
||||
const key = normaliseKeyName(e.key);
|
||||
|
||||
if (key && hasModifier) {
|
||||
const hotkey = buildHotkeyString(mods, key);
|
||||
onChange(hotkey);
|
||||
setRecording(false);
|
||||
setLiveKeys(null);
|
||||
} else if (!key) {
|
||||
// Only modifiers held — show live preview
|
||||
const preview = buildModifierPreview(mods);
|
||||
if (preview) setLiveKeys(preview);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
};
|
||||
}, [recording, onChange, stopRecording]);
|
||||
|
||||
// Click outside cancels recording
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (recorderRef.current && !recorderRef.current.contains(e.target as Node)) {
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [recording, stopRecording]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={recorderRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setRecording(true);
|
||||
setLiveKeys(null);
|
||||
}
|
||||
}}
|
||||
aria-label={recording ? 'Press a key combination to set shortcut' : `Change shortcut, currently ${value}`}
|
||||
className={`
|
||||
group relative flex items-center gap-2.5 rounded-lg border px-4 py-3 text-left
|
||||
transition-all duration-200 outline-none
|
||||
${recording
|
||||
? 'border-[var(--color-accent)]/50 bg-[var(--color-accent)]/[0.06] ring-1 ring-[var(--color-accent)]/20'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-[var(--color-border)] opacity-50'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/30 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Key display */}
|
||||
<div className="flex flex-1 items-center gap-1.5">
|
||||
{recording && !liveKeys ? (
|
||||
<span className="flex items-center gap-2 text-[12px] text-[var(--color-text-accent)]">
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[var(--color-accent)] opacity-40" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-[var(--color-accent)]" />
|
||||
</span>
|
||||
Press a key combo…
|
||||
</span>
|
||||
) : (
|
||||
displayTokens.map((key, i) => (
|
||||
<kbd
|
||||
key={`${key}-${i}`}
|
||||
className={`
|
||||
rounded-[5px] border px-2 py-[3px] font-mono text-[11px] font-medium leading-none shadow-sm
|
||||
transition-all duration-200
|
||||
${recording
|
||||
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 text-[var(--color-text-accent)] shadow-[var(--color-accent)]/10'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] shadow-black/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action hint */}
|
||||
<span
|
||||
className={`
|
||||
shrink-0 rounded-md px-2 py-1 text-[10px] font-semibold tracking-wide uppercase
|
||||
transition-all duration-200
|
||||
${recording
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
|
||||
: 'text-[var(--color-text-muted)] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{recording ? 'Esc to cancel' : 'Click to change'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildModifierPreview(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }): string | null {
|
||||
const parts: string[] = [];
|
||||
if (mods.ctrl) parts.push('Ctrl');
|
||||
if (mods.alt) parts.push('Alt');
|
||||
if (mods.shift) parts.push('Shift');
|
||||
if (mods.meta) parts.push('Super');
|
||||
return parts.length > 0 ? parts.join('+') + '+…' : null;
|
||||
}
|
||||
@@ -7,5 +7,6 @@ export { TextInput } from './TextInput';
|
||||
export { TextareaInput } from './TextareaInput';
|
||||
export { SelectInput } from './SelectInput';
|
||||
export { InfoCallout } from './InfoCallout';
|
||||
export { HotkeyRecorder, hotkeyToDisplayTokens } from './HotkeyRecorder';
|
||||
export type { UpdateBannerProps } from './UpdateBanner';
|
||||
export { UpdateBanner } from './UpdateBanner';
|
||||
|
||||
Reference in New Issue
Block a user