mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
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 { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||||
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
||||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||||
import { isMac } from '@renderer/lib/platform';
|
|
||||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } 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 resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
|
||||||
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
|
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
|
// Group models by tier for the dropdown
|
||||||
const tierOrder = ['premium', 'standard', 'fast'] as const;
|
const tierOrder = ['premium', 'standard', 'fast'] as const;
|
||||||
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
|
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
|
||||||
@@ -1372,7 +1364,7 @@ function QuickPromptSettingsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
||||||
<button
|
<button
|
||||||
className="flex flex-1 items-start gap-0 text-left"
|
className="flex flex-1 items-start gap-0 text-left"
|
||||||
@@ -1381,18 +1373,11 @@ function QuickPromptSettingsSection({
|
|||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||||
Global hotkey
|
Enable global hotkey
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1.5 flex items-center gap-1">
|
<p className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
{hotkeyKeys.map((key) => (
|
Summon Quick Prompt from any app
|
||||||
<kbd
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
|
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
|
||||||
@@ -1400,6 +1385,20 @@ function QuickPromptSettingsSection({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Default Model — compact dropdown selector */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
<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 { TextareaInput } from './TextareaInput';
|
||||||
export { SelectInput } from './SelectInput';
|
export { SelectInput } from './SelectInput';
|
||||||
export { InfoCallout } from './InfoCallout';
|
export { InfoCallout } from './InfoCallout';
|
||||||
|
export { HotkeyRecorder, hotkeyToDisplayTokens } from './HotkeyRecorder';
|
||||||
export type { UpdateBannerProps } from './UpdateBanner';
|
export type { UpdateBannerProps } from './UpdateBanner';
|
||||||
export { UpdateBanner } from './UpdateBanner';
|
export { UpdateBanner } from './UpdateBanner';
|
||||||
|
|||||||
Reference in New Issue
Block a user