feat(electrobun): settings overhaul — fonts, shells, providers, retention, chords
- Settings drawer: responsive width clamp(24rem, 45vw, 50rem) - System font detection: fc-list for UI fonts (preferred sans-serif starred) and mono fonts (Nerd Fonts starred), fallback to hardcoded lists - Scrollback: default 5000, min 1000, step 500 - Shell detection: system.shells RPC, pre-selects $SHELL login shell - Provider enablement: provider.scan gates toggle, unavailable shown as N/A - Session retention: count 0-100 (0=Keep all), age 0-365 (0=Forever) - Chord keybindings: Ctrl+K → Ctrl+S style multi-key sequences, 1s prefix wait, arrow separator display, 26 tests passing
This commit is contained in:
parent
afaa2253de
commit
1de6c93e01
9 changed files with 346 additions and 187 deletions
|
|
@ -29,7 +29,57 @@ export function createGitHandlers() {
|
|||
// not installed
|
||||
}
|
||||
}
|
||||
return { shells };
|
||||
const loginShell = process.env.SHELL ?? '/bin/bash';
|
||||
return { shells, loginShell };
|
||||
},
|
||||
|
||||
"system.fonts": async () => {
|
||||
const PREFERRED_SANS = new Set([
|
||||
'Inter', 'Roboto', 'Noto Sans', 'Ubuntu', 'Open Sans', 'Lato',
|
||||
'Source Sans 3', 'IBM Plex Sans', 'Fira Sans', 'PT Sans',
|
||||
'Cantarell', 'DejaVu Sans', 'Liberation Sans',
|
||||
]);
|
||||
try {
|
||||
const allRaw = execSync(
|
||||
'fc-list :style=Regular --format="%{family}\\n" | sort -u',
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
const monoRaw = execSync(
|
||||
'fc-list :spacing=mono --format="%{family}\\n" | sort -u',
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
|
||||
const monoSet = new Set<string>();
|
||||
const monoFonts: Array<{ family: string; isNerdFont: boolean }> = [];
|
||||
for (const line of monoRaw.split('\n')) {
|
||||
const family = line.split(',')[0].trim(); // fc-list returns comma-separated aliases
|
||||
if (!family || monoSet.has(family)) continue;
|
||||
monoSet.add(family);
|
||||
monoFonts.push({ family, isNerdFont: family.includes('Nerd') });
|
||||
}
|
||||
monoFonts.sort((a, b) => {
|
||||
if (a.isNerdFont !== b.isNerdFont) return a.isNerdFont ? -1 : 1;
|
||||
return a.family.localeCompare(b.family);
|
||||
});
|
||||
|
||||
const uiSet = new Set<string>();
|
||||
const uiFonts: Array<{ family: string; preferred: boolean }> = [];
|
||||
for (const line of allRaw.split('\n')) {
|
||||
const family = line.split(',')[0].trim();
|
||||
if (!family || uiSet.has(family) || monoSet.has(family)) continue;
|
||||
uiSet.add(family);
|
||||
uiFonts.push({ family, preferred: PREFERRED_SANS.has(family) });
|
||||
}
|
||||
uiFonts.sort((a, b) => {
|
||||
if (a.preferred !== b.preferred) return a.preferred ? -1 : 1;
|
||||
return a.family.localeCompare(b.family);
|
||||
});
|
||||
|
||||
return { uiFonts, monoFonts };
|
||||
} catch (err) {
|
||||
console.error('[system.fonts] fc-list failed:', err);
|
||||
return { uiFonts: [], monoFonts: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"ssh.checkSshfs": async () => {
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: 30rem;
|
||||
width: clamp(24rem, 45vw, 50rem);
|
||||
max-width: 95vw;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* Svelte 5 rune-based keybinding store.
|
||||
* Manages global keyboard shortcuts with user-customizable chords.
|
||||
* Supports multi-key chord sequences (e.g. "Ctrl+K Ctrl+S").
|
||||
* Persists overrides via settings RPC (only non-default bindings saved).
|
||||
*
|
||||
* Usage:
|
||||
|
|
@ -30,6 +31,7 @@ export interface Keybinding {
|
|||
id: string;
|
||||
label: string;
|
||||
category: "Global" | "Navigation" | "Terminal" | "Settings";
|
||||
/** Space-separated chord sequence, e.g. "Ctrl+K" or "Ctrl+K Ctrl+S". */
|
||||
chord: string;
|
||||
defaultChord: string;
|
||||
}
|
||||
|
|
@ -57,6 +59,7 @@ const DEFAULTS: Keybinding[] = [
|
|||
|
||||
// ── Chord serialisation helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Convert a KeyboardEvent to a single-step chord string like "Ctrl+Shift+K". */
|
||||
export function chordFromEvent(e: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
|
||||
|
|
@ -70,8 +73,24 @@ export function chordFromEvent(e: KeyboardEvent): string {
|
|||
return parts.join("+");
|
||||
}
|
||||
|
||||
function matchesChord(e: KeyboardEvent, chord: string): boolean {
|
||||
return chordFromEvent(e) === chord;
|
||||
/** Split a chord string into its sequence parts. "Ctrl+K Ctrl+S" → ["Ctrl+K", "Ctrl+S"] */
|
||||
export function chordParts(chord: string): string[] {
|
||||
return chord.split(" ").filter(Boolean);
|
||||
}
|
||||
|
||||
/** Format a chord for display: "Ctrl+K Ctrl+S" → "Ctrl+K → Ctrl+S" */
|
||||
export function formatChord(chord: string): string {
|
||||
const parts = chordParts(chord);
|
||||
return parts.join(" \u2192 ");
|
||||
}
|
||||
|
||||
/** Check if a chord string is the first key of any multi-key chord binding. */
|
||||
function isChordPrefix(firstKey: string, bindings: Keybinding[]): boolean {
|
||||
for (const b of bindings) {
|
||||
const parts = chordParts(b.chord);
|
||||
if (parts.length > 1 && parts[0] === firstKey) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -82,6 +101,15 @@ function createKeybindingStore() {
|
|||
const handlers = new Map<string, () => void>();
|
||||
let listenerInstalled = false;
|
||||
|
||||
// Chord sequence state
|
||||
let pendingPrefix: string | null = null;
|
||||
let prefixTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearPrefix() {
|
||||
pendingPrefix = null;
|
||||
if (prefixTimer) { clearTimeout(prefixTimer); prefixTimer = null; }
|
||||
}
|
||||
|
||||
/** Load persisted overrides and merge with defaults. */
|
||||
async function init(rpcInstance: SettingsRpc): Promise<void> {
|
||||
rpc = rpcInstance;
|
||||
|
|
@ -135,8 +163,39 @@ function createKeybindingStore() {
|
|||
const chord = chordFromEvent(e);
|
||||
if (!chord) return;
|
||||
|
||||
// If we have a pending prefix, try to complete the chord sequence
|
||||
if (pendingPrefix) {
|
||||
const fullChord = `${pendingPrefix} ${chord}`;
|
||||
clearPrefix();
|
||||
|
||||
for (const b of bindings) {
|
||||
if (b.chord === fullChord) {
|
||||
const handler = handlers.get(b.id);
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No match for the full sequence — fall through to single-key check
|
||||
}
|
||||
|
||||
// Check if this chord is a prefix for any multi-key binding
|
||||
if (isChordPrefix(chord, bindings)) {
|
||||
e.preventDefault();
|
||||
pendingPrefix = chord;
|
||||
// Wait 1 second for the second key; timeout clears prefix
|
||||
prefixTimer = setTimeout(clearPrefix, 1000);
|
||||
|
||||
// Also check if this chord alone matches a single-key binding
|
||||
// (handled on timeout or if no second key matches)
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-key chord match
|
||||
for (const b of bindings) {
|
||||
if (b.chord === chord) {
|
||||
if (b.chord === chord && chordParts(b.chord).length === 1) {
|
||||
const handler = handlers.get(b.id);
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
|
|
@ -151,17 +210,20 @@ function createKeybindingStore() {
|
|||
return () => {
|
||||
document.removeEventListener("keydown", handleKeydown, { capture: true });
|
||||
listenerInstalled = false;
|
||||
clearPrefix();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get bindings() { return bindings; },
|
||||
get pendingPrefix() { return pendingPrefix; },
|
||||
init,
|
||||
setChord,
|
||||
resetChord,
|
||||
resetAll,
|
||||
on,
|
||||
installListener,
|
||||
clearPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
let permissionMode = $state<PermMode>('bypassPermissions');
|
||||
let systemPrompt = $state('');
|
||||
|
||||
// Detected shells from system.shells RPC
|
||||
let detectedShells = $state<Array<{ path: string; name: string }>>([
|
||||
{ path: '/bin/bash', name: 'bash' },
|
||||
]);
|
||||
|
||||
interface ProviderState { enabled: boolean; model: string; }
|
||||
let providerState = $state<Record<string, ProviderState>>({
|
||||
claude: { enabled: true, model: 'claude-opus-4-5' },
|
||||
|
|
@ -33,6 +38,10 @@
|
|||
gemini: { enabled: false, model: 'gemini-2.5-pro' },
|
||||
});
|
||||
|
||||
// Provider availability from provider.scan RPC
|
||||
interface ProviderAvail { available: boolean; hasApiKey: boolean; hasCli: boolean }
|
||||
let providerAvail = $state<Record<string, ProviderAvail>>({});
|
||||
|
||||
let expandedProvider = $state<string | null>(null);
|
||||
|
||||
function persist(key: string, value: string) {
|
||||
|
|
@ -49,11 +58,20 @@
|
|||
function setPrompt(v: string) { systemPrompt = v; persist('system_prompt_template', v); }
|
||||
|
||||
function toggleProvider(id: string) {
|
||||
// Don't allow enabling unavailable providers
|
||||
const avail = providerAvail[id];
|
||||
if (avail && !avail.available && !providerState[id].enabled) return;
|
||||
providerState[id] = { ...providerState[id], enabled: !providerState[id].enabled };
|
||||
providerState = { ...providerState };
|
||||
persistProviders();
|
||||
}
|
||||
|
||||
function isProviderAvailable(id: string): boolean {
|
||||
const avail = providerAvail[id];
|
||||
if (!avail) return true; // Not scanned yet — assume available
|
||||
return avail.available;
|
||||
}
|
||||
|
||||
function setModel(id: string, model: string) {
|
||||
providerState[id] = { ...providerState[id], model };
|
||||
providerState = { ...providerState };
|
||||
|
|
@ -63,21 +81,48 @@
|
|||
onMount(async () => {
|
||||
if (!appRpc) return;
|
||||
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
|
||||
if (settings['default_shell']) defaultShell = settings['default_shell'];
|
||||
if (settings['default_cwd']) defaultCwd = settings['default_cwd'];
|
||||
if (settings['permission_mode']) permissionMode = settings['permission_mode'] as PermMode;
|
||||
if (settings['system_prompt_template']) systemPrompt = settings['system_prompt_template'];
|
||||
if (settings['provider_settings']) {
|
||||
try { providerState = JSON.parse(settings['provider_settings']); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Detect installed shells and pre-select login shell
|
||||
try {
|
||||
const shellRes = await appRpc.request['system.shells']({});
|
||||
if (shellRes.shells.length > 0) detectedShells = shellRes.shells;
|
||||
// Use persisted shell, else login shell from $SHELL, else first detected
|
||||
if (settings['default_shell']) {
|
||||
defaultShell = settings['default_shell'];
|
||||
} else {
|
||||
defaultShell = shellRes.loginShell || detectedShells[0]?.path || '/bin/bash';
|
||||
}
|
||||
} catch {
|
||||
if (settings['default_shell']) defaultShell = settings['default_shell'];
|
||||
}
|
||||
|
||||
// Scan provider availability
|
||||
try {
|
||||
const provRes = await appRpc.request['provider.scan']({});
|
||||
const avail: Record<string, ProviderAvail> = {};
|
||||
for (const p of provRes.providers) {
|
||||
avail[p.id] = { available: p.available, hasApiKey: p.hasApiKey, hasCli: p.hasCli };
|
||||
}
|
||||
providerAvail = avail;
|
||||
} catch { /* keep defaults */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
<Section heading="Defaults">
|
||||
<div class="field">
|
||||
<label class="lbl" for="ag-shell">Shell</label>
|
||||
<input id="ag-shell" class="text-in" value={defaultShell} placeholder="/bin/bash"
|
||||
onchange={e => setShell((e.target as HTMLInputElement).value)} />
|
||||
<select id="ag-shell" class="text-in" value={defaultShell}
|
||||
onchange={e => setShell((e.target as HTMLSelectElement).value)}>
|
||||
{#each detectedShells as shell}
|
||||
<option value={shell.path} selected={shell.path === defaultShell}>{shell.name} ({shell.path})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
@ -101,18 +146,25 @@
|
|||
<div class="prov-list">
|
||||
{#each PROVIDERS as prov}
|
||||
{@const state = providerState[prov.id]}
|
||||
<div class="prov-panel" class:disabled={!state.enabled}>
|
||||
{@const available = isProviderAvailable(prov.id)}
|
||||
<div class="prov-panel" class:disabled={!state.enabled} class:unavailable={!available}>
|
||||
<button class="prov-hdr" onclick={() => expandedProvider = expandedProvider === prov.id ? null : prov.id}>
|
||||
<span class="prov-name">{prov.label}</span>
|
||||
<span class="prov-desc">{prov.desc}</span>
|
||||
{#if !available}<span class="prov-badge" title="Not installed">N/A</span>{/if}
|
||||
<span class="prov-chev">{expandedProvider === prov.id ? '\u25B4' : '\u25BE'}</span>
|
||||
</button>
|
||||
{#if expandedProvider === prov.id}
|
||||
<div class="prov-body">
|
||||
{#if !available}
|
||||
<div class="prov-unavail">Not installed — install the CLI or set an API key to enable.</div>
|
||||
{/if}
|
||||
<label class="toggle-row">
|
||||
<span class="lbl">Enabled</span>
|
||||
<button class="toggle" class:on={state.enabled} role="switch"
|
||||
aria-checked={state.enabled} aria-label="Toggle {prov.label} provider"
|
||||
disabled={!available && !state.enabled}
|
||||
title={!available ? 'Not installed' : ''}
|
||||
onclick={() => toggleProvider(prov.id)}><span class="thumb"></span></button>
|
||||
</label>
|
||||
<div class="field">
|
||||
|
|
@ -167,6 +219,10 @@
|
|||
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
|
||||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.prov-panel.unavailable { opacity: 0.6; border-style: dashed; }
|
||||
.prov-badge { padding: 0.0625rem 0.375rem; background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent); color: var(--ctp-yellow); border-radius: 0.75rem; font-size: 0.6rem; font-weight: 600; flex-shrink: 0; }
|
||||
.prov-unavail { padding: 0.25rem 0.375rem; font-size: 0.7rem; color: var(--ctp-overlay0); font-style: italic; }
|
||||
|
||||
.caps { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.cap { padding: 0.125rem 0.5rem; background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); color: var(--ctp-blue); border-radius: 0.75rem; font-size: 0.65rem; font-weight: 500; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
import ThemeEditor from './ThemeEditor.svelte';
|
||||
import CustomDropdown from '../ui/CustomDropdown.svelte';
|
||||
|
||||
const UI_FONTS = [
|
||||
// Fallback lists — replaced by system detection on mount
|
||||
const FALLBACK_UI_FONTS = [
|
||||
{ value: '', label: 'System Default' },
|
||||
{ value: 'Inter', label: 'Inter' },
|
||||
{ value: 'IBM Plex Sans', label: 'IBM Plex Sans' },
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
{ value: 'Ubuntu', label: 'Ubuntu' },
|
||||
];
|
||||
|
||||
const TERM_FONTS = [
|
||||
const FALLBACK_TERM_FONTS = [
|
||||
{ value: '', label: 'Default (JetBrains Mono)' },
|
||||
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
|
||||
{ value: 'Fira Code', label: 'Fira Code' },
|
||||
|
|
@ -27,6 +28,9 @@
|
|||
{ value: 'monospace', label: 'monospace' },
|
||||
];
|
||||
|
||||
let uiFontItems = $state(FALLBACK_UI_FONTS.map(f => ({ value: f.value, label: f.label })));
|
||||
let termFontItems = $state(FALLBACK_TERM_FONTS.map(f => ({ value: f.value, label: f.label })));
|
||||
|
||||
// ── Local reactive state ───────────────────────────────────────────────────
|
||||
let themeId = $state<ThemeId>(themeStore.currentTheme);
|
||||
let uiFont = $state(fontStore.uiFontFamily);
|
||||
|
|
@ -35,7 +39,7 @@
|
|||
let termFontSize = $state(fontStore.termFontSize);
|
||||
let cursorStyle = $state('block');
|
||||
let cursorBlink = $state(true);
|
||||
let scrollback = $state(1000);
|
||||
let scrollback = $state(5000);
|
||||
|
||||
interface CustomThemeMeta { id: string; name: string; }
|
||||
let customThemes = $state<CustomThemeMeta[]>([]);
|
||||
|
|
@ -67,8 +71,7 @@
|
|||
|
||||
// ── Dropdown items for CustomDropdown ──────────────────────────────────────
|
||||
let themeItems = $derived(allThemes.map(t => ({ value: t.id, label: t.label, group: t.group })));
|
||||
let uiFontItems = UI_FONTS.map(f => ({ value: f.value, label: f.label }));
|
||||
let termFontItems = TERM_FONTS.map(f => ({ value: f.value, label: f.label }));
|
||||
// uiFontItems and termFontItems are $state — populated by system.fonts on mount
|
||||
let langItems = AVAILABLE_LOCALES.map(l => ({ value: l.tag, label: l.nativeLabel }));
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -135,10 +138,35 @@
|
|||
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
|
||||
if (settings['cursor_style']) cursorStyle = settings['cursor_style'];
|
||||
if (settings['cursor_blink']) cursorBlink = settings['cursor_blink'] !== 'false';
|
||||
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 1000;
|
||||
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 5000;
|
||||
|
||||
const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] }));
|
||||
customThemes = res.themes.map(t => ({ id: t.id, name: t.name }));
|
||||
|
||||
// Detect system fonts
|
||||
try {
|
||||
const fontRes = await appRpc.request['system.fonts']({});
|
||||
if (fontRes.uiFonts.length > 0) {
|
||||
uiFontItems = [
|
||||
{ value: '', label: 'System Default' },
|
||||
...fontRes.uiFonts.map(f => ({
|
||||
value: f.family,
|
||||
label: f.preferred ? `\u2605 ${f.family}` : f.family,
|
||||
})),
|
||||
];
|
||||
}
|
||||
if (fontRes.monoFonts.length > 0) {
|
||||
termFontItems = [
|
||||
{ value: '', label: 'Default (JetBrains Mono)' },
|
||||
...fontRes.monoFonts.map(f => ({
|
||||
value: f.family,
|
||||
label: f.isNerdFont ? `\u2B50 ${f.family}` : f.family,
|
||||
})),
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback font lists
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -216,8 +244,8 @@
|
|||
|
||||
<h3 class="sh">{t('settings.scrollback')}</h3>
|
||||
<div class="field row">
|
||||
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
|
||||
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
|
||||
<input type="number" class="num-in" min="1000" max="100000" step="500" value={scrollback}
|
||||
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 5000)}
|
||||
aria-label="Scrollback lines" />
|
||||
<span class="hint">{t('settings.scrollbackHint')}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { keybindingStore, chordFromEvent, type Keybinding } from '../keybinding-store.svelte.ts';
|
||||
import { keybindingStore, chordFromEvent, formatChord, chordParts, type Keybinding } from '../keybinding-store.svelte.ts';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
let capturingId = $state<string | null>(null);
|
||||
let conflictWarning = $state<string | null>(null);
|
||||
// Chord capture: first key of a multi-key sequence
|
||||
let capturePrefix = $state<string | null>(null);
|
||||
let capturePrefixTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Derived filtered list ──────────────────────────────────────
|
||||
let filtered = $derived(
|
||||
|
|
@ -28,10 +31,28 @@
|
|||
|
||||
const CATEGORY_ORDER = ['Global', 'Navigation', 'Terminal', 'Settings'];
|
||||
|
||||
// Known chord prefixes for multi-key detection
|
||||
const KNOWN_PREFIXES = ['Ctrl+K', 'Ctrl+Shift+K'];
|
||||
|
||||
function isKnownPrefix(chord: string): boolean {
|
||||
return KNOWN_PREFIXES.includes(chord);
|
||||
}
|
||||
|
||||
// ── Capture mode ──────────────────────────────────────────────
|
||||
function startCapture(id: string) {
|
||||
capturingId = id;
|
||||
conflictWarning = null;
|
||||
capturePrefix = null;
|
||||
if (capturePrefixTimer) clearTimeout(capturePrefixTimer);
|
||||
capturePrefixTimer = null;
|
||||
}
|
||||
|
||||
function clearCapture() {
|
||||
capturingId = null;
|
||||
conflictWarning = null;
|
||||
capturePrefix = null;
|
||||
if (capturePrefixTimer) clearTimeout(capturePrefixTimer);
|
||||
capturePrefixTimer = null;
|
||||
}
|
||||
|
||||
function handleCaptureKeydown(e: KeyboardEvent, id: string) {
|
||||
|
|
@ -40,8 +61,7 @@
|
|||
|
||||
const chord = chordFromEvent(e);
|
||||
if (!chord || chord === 'Escape') {
|
||||
capturingId = null;
|
||||
conflictWarning = null;
|
||||
clearCapture();
|
||||
return;
|
||||
}
|
||||
// Skip bare modifier key presses (no actual key yet)
|
||||
|
|
@ -49,6 +69,31 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// If we already have a prefix, complete the chord sequence
|
||||
if (capturePrefix) {
|
||||
if (capturePrefixTimer) clearTimeout(capturePrefixTimer);
|
||||
const fullChord = `${capturePrefix} ${chord}`;
|
||||
applyChord(id, fullChord);
|
||||
return;
|
||||
}
|
||||
|
||||
// If this looks like a chord prefix, wait for second key
|
||||
if (isKnownPrefix(chord)) {
|
||||
capturePrefix = chord;
|
||||
capturePrefixTimer = setTimeout(() => {
|
||||
// Timeout — use the prefix as a single-key chord
|
||||
if (capturePrefix) {
|
||||
applyChord(id, capturePrefix);
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-key chord
|
||||
applyChord(id, chord);
|
||||
}
|
||||
|
||||
function applyChord(id: string, chord: string) {
|
||||
// Conflict check
|
||||
const conflict = keybindingStore.bindings.find(
|
||||
(b) => b.id !== id && b.chord === chord
|
||||
|
|
@ -61,7 +106,7 @@
|
|||
}
|
||||
|
||||
keybindingStore.setChord(id, chord);
|
||||
capturingId = null;
|
||||
clearCapture();
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
|
|
@ -80,7 +125,7 @@
|
|||
<input
|
||||
class="kb-search"
|
||||
type="search"
|
||||
placeholder="Search shortcuts…"
|
||||
placeholder="Search shortcuts\u2026"
|
||||
bind:value={searchQuery}
|
||||
aria-label="Search keyboard shortcuts"
|
||||
/>
|
||||
|
|
@ -115,18 +160,18 @@
|
|||
aria-label="Press new key combination for {binding.label}"
|
||||
autofocus
|
||||
onkeydown={(e) => handleCaptureKeydown(e, binding.id)}
|
||||
onblur={() => capturingId = null}
|
||||
onblur={() => clearCapture()}
|
||||
>
|
||||
Press keys…
|
||||
{capturePrefix ? `${capturePrefix} + \u2026` : 'Press keys\u2026'}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="kb-chord"
|
||||
onclick={() => startCapture(binding.id)}
|
||||
title="Click to rebind"
|
||||
aria-label="Current shortcut for {binding.label}: {binding.chord}. Click to change."
|
||||
aria-label="Current shortcut for {binding.label}: {formatChord(binding.chord)}. Click to change."
|
||||
>
|
||||
{binding.chord}
|
||||
{formatChord(binding.chord)}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
|
@ -135,10 +180,10 @@
|
|||
<button
|
||||
class="kb-reset"
|
||||
onclick={() => keybindingStore.resetChord(binding.id)}
|
||||
title="Reset to {binding.defaultChord}"
|
||||
title="Reset to {formatChord(binding.defaultChord)}"
|
||||
aria-label="Reset {binding.label} to default"
|
||||
>
|
||||
↩ {binding.defaultChord}
|
||||
\u21A9 {formatChord(binding.defaultChord)}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="kb-reset-placeholder"></span>
|
||||
|
|
@ -156,160 +201,26 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.kb-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kb-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kb-search {
|
||||
flex: 1;
|
||||
height: 1.75rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0 0.5rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kb-settings { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.kb-toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.kb-search { flex: 1; height: 1.75rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem; color: var(--ctp-text); font-family: var(--ui-font-family); font-size: 0.8125rem; padding: 0 0.5rem; outline: none; }
|
||||
.kb-search:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.kb-reset-all {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset-all:hover {
|
||||
border-color: var(--ctp-red);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.kb-conflict-banner {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||
border: 1px solid var(--ctp-yellow);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.kb-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kb-category-header {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.kb-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.kb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.kb-reset-all { padding: 0.25rem 0.625rem; background: transparent; border: 1px solid var(--ctp-surface1); border-radius: 0.3rem; color: var(--ctp-subtext0); font-family: var(--ui-font-family); font-size: 0.75rem; cursor: pointer; white-space: nowrap; transition: border-color 0.12s, color 0.12s; }
|
||||
.kb-reset-all:hover { border-color: var(--ctp-red); color: var(--ctp-red); }
|
||||
.kb-conflict-banner { padding: 0.375rem 0.5rem; background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent); border: 1px solid var(--ctp-yellow); border-radius: 0.3rem; color: var(--ctp-yellow); font-size: 0.75rem; }
|
||||
.kb-category { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.kb-category-header { font-size: 0.625rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ctp-overlay0); padding: 0 0.25rem; }
|
||||
.kb-table { display: flex; flex-direction: column; gap: 0.125rem; }
|
||||
.kb-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 0.5rem; padding: 0.25rem 0.375rem; border-radius: 0.25rem; transition: background 0.1s; }
|
||||
.kb-row:hover { background: var(--ctp-surface0); }
|
||||
.kb-row.modified { background: color-mix(in srgb, var(--ctp-mauve) 6%, transparent); }
|
||||
|
||||
.kb-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord:hover:not(.capturing) {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.kb-chord.capturing {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0));
|
||||
color: var(--ctp-mauve);
|
||||
animation: pulse-capture 0.8s ease-in-out infinite;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-capture {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.kb-reset {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset:hover {
|
||||
border-color: var(--ctp-surface1);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.kb-reset-placeholder {
|
||||
width: 5rem; /* Reserve space so layout stays stable */
|
||||
}
|
||||
|
||||
.kb-empty {
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.kb-label { font-size: 0.8125rem; color: var(--ctp-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.kb-chord { padding: 0.125rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-family: var(--term-font-family); font-size: 0.75rem; cursor: pointer; transition: border-color 0.12s, background 0.12s; white-space: nowrap; }
|
||||
.kb-chord:hover:not(.capturing) { border-color: var(--ctp-mauve); background: var(--ctp-surface1); }
|
||||
.kb-chord.capturing { border-color: var(--ctp-mauve); background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0)); color: var(--ctp-mauve); animation: pulse-capture 0.8s ease-in-out infinite; outline: none; }
|
||||
@keyframes pulse-capture { 0%, 100% { opacity: 1; } 50% { opacity: 0.65; } }
|
||||
.kb-reset { padding: 0.125rem 0.375rem; background: transparent; border: 1px solid transparent; border-radius: 0.25rem; color: var(--ctp-overlay0); font-family: var(--ui-font-family); font-size: 0.6875rem; cursor: pointer; white-space: nowrap; transition: border-color 0.12s, color 0.12s; }
|
||||
.kb-reset:hover { border-color: var(--ctp-surface1); color: var(--ctp-subtext0); }
|
||||
.kb-reset-placeholder { width: 5rem; }
|
||||
.kb-empty { text-align: center; color: var(--ctp-overlay0); font-size: 0.8125rem; font-style: italic; padding: 2rem 0; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -137,17 +137,17 @@
|
|||
<h3 class="sh" style="margin-top: 0.625rem;">Session retention</h3>
|
||||
<div class="slider-row">
|
||||
<span class="lbl">Keep last</span>
|
||||
<input type="range" min="1" max="20" step="1" value={sessionRetentionCount}
|
||||
<input type="range" min="0" max="100" step="1" value={sessionRetentionCount}
|
||||
oninput={e => updateRetention('count', parseInt((e.target as HTMLInputElement).value, 10))}
|
||||
/>
|
||||
<span class="slider-val">{sessionRetentionCount}</span>
|
||||
<span class="slider-val">{sessionRetentionCount === 0 ? '\u221E' : sessionRetentionCount}</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="lbl">Max age</span>
|
||||
<input type="range" min="1" max="90" step="1" value={sessionRetentionDays}
|
||||
<input type="range" min="0" max="365" step="1" value={sessionRetentionDays}
|
||||
oninput={e => updateRetention('days', parseInt((e.target as HTMLInputElement).value, 10))}
|
||||
/>
|
||||
<span class="slider-val">{sessionRetentionDays}d</span>
|
||||
<span class="slider-val">{sessionRetentionDays === 0 ? 'Forever' : sessionRetentionDays + 'd'}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Custom context</h3>
|
||||
|
|
|
|||
|
|
@ -255,7 +255,16 @@ export type PtyRPCRequests = {
|
|||
/** Detect available shells on this system. */
|
||||
"system.shells": {
|
||||
params: Record<string, never>;
|
||||
response: { shells: Array<{ path: string; name: string }> };
|
||||
response: { shells: Array<{ path: string; name: string }>; loginShell: string };
|
||||
};
|
||||
|
||||
/** Detect installed system fonts (uses fc-list). */
|
||||
"system.fonts": {
|
||||
params: Record<string, never>;
|
||||
response: {
|
||||
uiFonts: Array<{ family: string; preferred: boolean }>;
|
||||
monoFonts: Array<{ family: string; isNerdFont: boolean }>;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Project templates RPC ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -202,10 +202,53 @@ describe('conflict detection', () => {
|
|||
|
||||
describe('capture mode', () => {
|
||||
it('chordFromEvent records full chord for capture', () => {
|
||||
// Simulate user pressing Ctrl+Shift+X in capture mode
|
||||
const chord = chordFromEvent({
|
||||
ctrlKey: true, metaKey: false, shiftKey: true, altKey: false, key: 'x',
|
||||
});
|
||||
expect(chord).toBe('Ctrl+Shift+X');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chord sequence helpers ──────────────────────────────────────────────────
|
||||
|
||||
function chordParts(chord: string): string[] {
|
||||
return chord.split(' ').filter(Boolean);
|
||||
}
|
||||
|
||||
function formatChord(chord: string): string {
|
||||
return chordParts(chord).join(' \u2192 ');
|
||||
}
|
||||
|
||||
describe('chord sequences', () => {
|
||||
it('chordParts splits single chord', () => {
|
||||
expect(chordParts('Ctrl+K')).toEqual(['Ctrl+K']);
|
||||
});
|
||||
|
||||
it('chordParts splits multi-key chord', () => {
|
||||
expect(chordParts('Ctrl+K Ctrl+S')).toEqual(['Ctrl+K', 'Ctrl+S']);
|
||||
});
|
||||
|
||||
it('formatChord adds arrow separator', () => {
|
||||
expect(formatChord('Ctrl+K Ctrl+S')).toBe('Ctrl+K \u2192 Ctrl+S');
|
||||
});
|
||||
|
||||
it('formatChord passes through single chord', () => {
|
||||
expect(formatChord('Ctrl+Shift+F')).toBe('Ctrl+Shift+F');
|
||||
});
|
||||
|
||||
it('setChord stores chord sequence', () => {
|
||||
const state = createKeybindingState();
|
||||
state.setChord('palette', 'Ctrl+K Ctrl+P');
|
||||
const b = state.getBindings().find(b => b.id === 'palette');
|
||||
expect(b?.chord).toBe('Ctrl+K Ctrl+P');
|
||||
});
|
||||
|
||||
it('conflict detection works with chord sequences', () => {
|
||||
const state = createKeybindingState();
|
||||
state.setChord('palette', 'Ctrl+K Ctrl+P');
|
||||
state.setChord('settings', 'Ctrl+K Ctrl+P');
|
||||
const conflicts = state.findConflicts('Ctrl+K Ctrl+P', 'palette');
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0].id).toBe('settings');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue