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:
David Kaya
2026-04-15 08:14:58 +02:00
3 changed files with 243 additions and 21 deletions
+20 -21
View File
@@ -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;
}
+1
View File
@@ -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';