feat: simplify chord guide feature; updated help doc; added hotkeys doc

This commit is contained in:
Xiaohan-Tian
2026-05-28 21:03:02 -07:00
parent cd4d5847eb
commit 4d415a7f7d
18 changed files with 522 additions and 51 deletions
+10 -5
View File
@@ -10,6 +10,7 @@ import SpectrogramCanvas from './SpectrogramCanvas';
import AudioWaveformCanvas from './AudioWaveformCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
import { getNextChordCandidateIndex } from './chordGuideUtil';
interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -27,7 +28,9 @@ interface PianoGridProps {
regionStartBeat?: number;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
chordGuide: 'N' | 'T' | 'S' | 'D';
chordGuideKeySignature: KeySignature;
chordGuideMode: 'ionian' | 'aeolian';
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
@@ -59,6 +62,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature,
chordGuideMode,
audioRegion,
trackId,
projectName,
@@ -162,8 +167,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Use the utility function to get matching chords
const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
// Calculate chord highlights based on selected chord index
const chordHighlights = useMemo(() => {
@@ -212,9 +217,9 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Expose switchChord function via window for hotkey handler
useEffect(() => {
const switchChord = () => {
const switchChord = (direction: 1 | -1 = 1) => {
if (matchingChords.length > 1) {
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction));
}
};
+37
View File
@@ -253,6 +253,12 @@
margin-right: 5px;
}
.piano-roll-chord-guide-toolbar-group {
display: flex;
align-items: center;
margin-right: 5px;
}
.piano-roll-toolbar .automation-toggle-button {
width: 20px;
min-width: 20px;
@@ -290,6 +296,37 @@
font-size: 10px;
}
.piano-roll-toolbar .chord-guide-toggle-button {
margin: 0;
border-radius: 0;
border: 1px solid #444;
border-right-width: 0;
}
.piano-roll-toolbar .chord-guide-toggle-button-0 {
margin-left: 3px;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.piano-roll-toolbar .chord-guide-toggle-button-3 {
border-right-width: 1px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.piano-roll-toolbar .chord-guide-toggle-button.active {
border-color: #e0e0e0;
}
.piano-roll-toolbar .chord-guide-toggle-button.active:hover {
border-color: #f0f0f0;
}
.piano-roll-toolbar .chord-guide-toggle-button.active:active {
border-color: #d6d6d6;
}
/* Spectrogram toolbar controls */
.spectrogram-toolbar-controls {
display: flex;
+38 -12
View File
@@ -21,6 +21,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
import { matchesKeyboardShortcut } from '../../util/osUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
@@ -61,6 +62,7 @@ import {
getScrollLeftForViewportRequest,
type PendingModeSwitchRequest,
} from './pianoRollViewport';
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
interface PianoRollProps {
onClose: () => void;
@@ -126,7 +128,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [snapping, setSnapping] = useState<string>('NO SNAP');
// Chord guide state
const [chordGuide, setChordGuide] = useState<string>('N');
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
// Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
@@ -161,6 +163,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
() => parseSheetQuantization(sheetQuantization),
[sheetQuantization]
);
const chordGuideContext = useMemo(() => {
const project = KGCore.instance().getCurrentProject();
return resolveChordGuideContext(project, playheadPosition);
}, [playheadPosition]);
const effectiveChordGuideKeySignature = chordGuideContext.keySignature;
const chordGuideMode = chordGuideContext.mode;
const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
@@ -721,11 +729,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, [setSelectedMode]);
// Handle chord guide selection
const handleChordGuideSelect = useCallback((value: string) => {
const handleChordGuideSelect = useCallback((value: ChordGuideFunction) => {
setChordGuide(value);
}, []);
// Update suitable chords whenever chord guide, key signature, or mode changes
// Update suitable chords whenever chord guide selection or effective key signature changes
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
@@ -740,7 +748,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} else {
// Get suitable chords for the selected function (T/S/D)
const functionType = chordGuide as 'T' | 'S' | 'D';
const suitableChords = getSuitableChords(keySignature, selectedMode, functionType);
const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType);
// Convert note names to pitch classes (ensuring ascending order)
const chordsPitchClasses: Record<string, number[]> = {};
@@ -769,11 +777,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords);
console.log(`Suitable chords for ${effectiveChordGuideKeySignature} in ${chordGuideMode} mode:`, suitableChords);
console.log(`Pitch classes:`, chordsPitchClasses);
}
}
}, [chordGuide, keySignature, selectedMode]);
}, [chordGuide, chordGuideMode, effectiveChordGuideKeySignature]);
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
@@ -1363,14 +1371,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle piano roll hotkeys
const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) {
// Chord guide switch hotkey
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
// Call the switchChord function exposed by PianoGrid
const chordGuideSwitchShortcut = configManager.get('hotkeys.piano_roll.switch') as string;
const chordGuideSwitchVoicingShortcut = configManager.get('hotkeys.piano_roll.switch_voicing') as string;
if (chordGuideSwitchShortcut && matchesKeyboardShortcut(event, chordGuideSwitchShortcut)) {
event.preventDefault();
setChordGuide((current) => getNextChordGuideSelection(current));
return;
}
if (chordGuide !== 'N' && matchesKeyboardShortcut(event, 'tab')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord();
switchChord(1);
event.preventDefault();
}
return;
}
if (chordGuide !== 'N' && chordGuideSwitchVoicingShortcut && matchesKeyboardShortcut(event, chordGuideSwitchVoicingShortcut)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord(-1);
event.preventDefault();
}
return;
@@ -1486,7 +1510,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return () => {
window.removeEventListener('keydown', handlePianoRollKeyDown);
};
}, [handleQuantSelect, handleSnappingSelect]);
}, [chordGuide, handleQuantSelect, handleSnappingSelect]);
// Get the title for the piano roll based on the active region
const getPianoRollTitle = () => {
@@ -1594,6 +1618,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={effectiveChordGuideKeySignature}
chordGuideMode={chordGuideMode}
mode={currentMode}
audioRegion={audioRegion}
trackId={trackId}
@@ -76,7 +76,7 @@ describe('PianoRollContent', () => {
tracks: [],
selectedMode: 'ionian',
keySignature: 'C major' as KeySignature,
chordGuide: 'N',
chordGuide: 'N' as const,
bpm: 120,
};
@@ -36,7 +36,9 @@ interface PianoRollContentProps {
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
chordGuide: 'N' | 'T' | 'S' | 'D';
chordGuideKeySignature?: KeySignature;
chordGuideMode?: 'ionian' | 'aeolian';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion;
trackId?: string;
@@ -73,6 +75,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature = keySignature,
chordGuideMode = 'ionian',
mode = 'midi-edit',
audioRegion,
trackId,
@@ -376,6 +380,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={chordGuideKeySignature}
chordGuideMode={chordGuideMode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
@@ -56,7 +56,7 @@ describe('PianoRollToolbar', () => {
onSnappingSelect: vi.fn(),
selectedMode: 'ionian',
onModeChange: vi.fn(),
chordGuide: 'N',
chordGuide: 'N' as const,
onChordGuideChange: vi.fn(),
zoom: 1,
onZoomChange: vi.fn(),
@@ -106,6 +106,46 @@ describe('PianoRollToolbar', () => {
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
});
it('renders chord guide toggle buttons and marks off as active by default', () => {
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
/>
);
expect(screen.getByRole('button', { name: 'Chord guide off' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide tonic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide subdominant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide dominant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide off' }).className).toContain('active');
expect(screen.queryByRole('button', { name: 'Chord' })).not.toBeInTheDocument();
});
it('emits the selected chord guide value when toggle buttons are clicked', () => {
const onChordGuideChange = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
onChordGuideChange={onChordGuideChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Chord guide off' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide tonic' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide subdominant' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide dominant' }));
expect(onChordGuideChange).toHaveBeenNthCalledWith(1, 'N');
expect(onChordGuideChange).toHaveBeenNthCalledWith(2, 'T');
expect(onChordGuideChange).toHaveBeenNthCalledWith(3, 'S');
expect(onChordGuideChange).toHaveBeenNthCalledWith(4, 'D');
});
it('hides automation controls in spectrogram mode', () => {
render(
<PianoRollToolbar
+24 -15
View File
@@ -16,6 +16,13 @@ const POWER_OPTIONS = [
{ label: 'Strong', value: '0.3' },
];
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
{ label: '⊘', value: 'N', ariaLabel: 'Chord guide off' },
{ label: 'T', value: 'T', ariaLabel: 'Chord guide tonic' },
{ label: 'S', value: 'S', ariaLabel: 'Chord guide subdominant' },
{ label: 'D', value: 'D', ariaLabel: 'Chord guide dominant' },
];
interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean;
audioSpectrogramEnabled?: boolean;
@@ -37,8 +44,8 @@ interface PianoRollToolbarProps {
onSnappingSelect: (value: string) => void;
selectedMode: string;
onModeChange: (value: string) => void;
chordGuide: string;
onChordGuideChange: (value: string) => void;
chordGuide: 'N' | 'T' | 'S' | 'D';
onChordGuideChange: (value: 'N' | 'T' | 'S' | 'D') => void;
blinkButton?: string | null;
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
thresholdDb?: number;
@@ -208,19 +215,21 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<KGDropdown
options={[
{ label: 'Guide: Disabled', value: 'N' },
{ label: 'Chord Guide: T', value: 'T' },
{ label: 'Chord Guide: S', value: 'S' },
{ label: 'Chord Guide: D', value: 'D' }
]}
value={chordGuide}
onChange={(value) => onChordGuideChange(value)}
label="Chord"
buttonClassName="chord-guide-dropdown"
showValueAsLabel={true}
/>
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
{CHORD_GUIDE_BUTTONS.map((button, index) => (
<button
key={button.value}
type="button"
className={`tool-button automation-toggle-button chord-guide-toggle-button chord-guide-toggle-button-${index} ${chordGuide === button.value ? 'active' : ''}`}
onClick={() => onChordGuideChange(button.value)}
title={button.ariaLabel}
aria-label={button.ariaLabel}
aria-pressed={chordGuide === button.value}
>
{button.label}
</button>
))}
</div>
</div>
)}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../../core/KGProject';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { getNextChordCandidateIndex, getNextChordGuideSelection, resolveChordGuideContext } from './chordGuideUtil';
describe('chordGuideUtil', () => {
it('cycles chord guide selection in the expected order', () => {
expect(getNextChordGuideSelection('N')).toBe('T');
expect(getNextChordGuideSelection('T')).toBe('S');
expect(getNextChordGuideSelection('S')).toBe('D');
expect(getNextChordGuideSelection('D')).toBe('N');
});
it('cycles candidate indices forward and backward with wraparound', () => {
expect(getNextChordCandidateIndex(0, 4, 1)).toBe(1);
expect(getNextChordCandidateIndex(3, 4, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 4, -1)).toBe(3);
expect(getNextChordCandidateIndex(2, 4, -1)).toBe(1);
});
it('leaves candidate index unchanged for empty or single-candidate lists', () => {
expect(getNextChordCandidateIndex(0, 0, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 1, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 1, -1)).toBe(0);
});
it('resolves ionian from the effective major key signature at the playhead beat', () => {
const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'dorian');
const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature);
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 2, 4),
]);
expect(resolveChordGuideContext(project, 8)).toEqual({
keySignature: 'G major',
mode: 'ionian',
});
});
it('resolves aeolian from the effective minor key signature at the playhead beat', () => {
const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'ionian');
const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature);
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 1, 4),
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'A minor', 1, 2, 4),
]);
expect(resolveChordGuideContext(project, 4)).toEqual({
keySignature: 'A minor',
mode: 'aeolian',
});
});
});
@@ -0,0 +1,42 @@
import type { KeySignature, KGProject } from '../../core/KGProject';
import { getEffectiveKeySignatureAtBeat } from '../../util/globalTrackUtil';
import { getChordGuideModeFromKeySignature } from '../../util/scaleUtil';
export type ChordGuideFunction = 'N' | 'T' | 'S' | 'D';
export type ChordGuideMode = 'ionian' | 'aeolian';
export function getNextChordGuideSelection(current: ChordGuideFunction): ChordGuideFunction {
switch (current) {
case 'N':
return 'T';
case 'T':
return 'S';
case 'S':
return 'D';
default:
return 'N';
}
}
export function getNextChordCandidateIndex(
currentIndex: number,
candidateCount: number,
direction: 1 | -1
): number {
if (candidateCount <= 1) {
return currentIndex;
}
return (currentIndex + direction + candidateCount) % candidateCount;
}
export function resolveChordGuideContext(project: KGProject, beat: number): {
keySignature: KeySignature;
mode: ChordGuideMode;
} {
const keySignature = getEffectiveKeySignatureAtBeat(project, beat);
return {
keySignature,
mode: getChordGuideModeFromKeySignature(keySignature),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { enforceDefaultHotkeysForAppConfig } from './ConfigManager';
const baseConfig = {
general: {} as never,
hotkeys: {
main: {} as never,
piano_roll: {
switch: 'g',
switch_voicing: 'shift+tab',
select: 'q',
pencil: 'w',
hold_to_create_note: 'ctrl',
snap_none: '1',
snap_1_4: '2',
snap_1_8: '3',
snap_1_16: '4',
qua_pos_1_4: '5',
qua_pos_1_8: '6',
qua_pos_1_16: '7',
qua_len_1_4: '8',
qua_len_1_8: '9',
qua_len_1_16: '0',
},
},
editor: {} as never,
chatbox: {} as never,
audio: {} as never,
templates: {} as never,
chord_guide: {} as never,
};
describe('enforceDefaultHotkeysForAppConfig', () => {
it('always replaces saved hotkeys with config defaults', () => {
const result = enforceDefaultHotkeysForAppConfig(
{
...baseConfig,
hotkeys: {
...baseConfig.hotkeys,
piano_roll: {
...baseConfig.hotkeys.piano_roll,
switch: 'tab',
switch_voicing: 'f',
},
},
} as never,
baseConfig as never
);
expect(result.hotkeys.main).toBe(baseConfig.hotkeys.main);
expect(result.hotkeys.piano_roll.switch).toBe('g');
expect(result.hotkeys.piano_roll.switch_voicing).toBe('shift+tab');
});
});
+18 -2
View File
@@ -65,6 +65,7 @@ interface AppConfig {
};
piano_roll: {
switch: string;
switch_voicing: string;
select: string;
pencil: string;
hold_to_create_note: string;
@@ -106,6 +107,13 @@ interface AppConfig {
[key: string]: unknown;
}
export function enforceDefaultHotkeysForAppConfig(config: AppConfig, defaultConfig: AppConfig): AppConfig {
return {
...config,
hotkeys: defaultConfig.hotkeys,
};
}
/**
* ConfigManager - Manages application configuration with IndexedDB persistence
* Implements the singleton pattern for global access
@@ -162,6 +170,7 @@ export class ConfigManager {
// Merge with default config (saved config overrides defaults)
this.config = this.mergeConfigs(this.defaultConfig!, savedConfig);
this.config = enforceDefaultHotkeysForAppConfig(this.config, this.defaultConfig!);
this.isInitialized = true;
console.log('ConfigManager initialized successfully with config:', this.config);
@@ -253,7 +262,8 @@ export class ConfigManager {
merge_regions: 'ctrl+j'
},
piano_roll: {
switch: 'tab',
switch: 'g',
switch_voicing: 'shift+tab',
select: 'q',
pencil: 'w',
hold_to_create_note: 'ctrl',
@@ -329,9 +339,10 @@ export class ConfigManager {
try {
const shouldSanitize = !this.isRunningOnLocalhost() &&
!this.config.general.persist_api_keys_non_localhost;
const configToPersist = shouldSanitize
const baseConfigToPersist = shouldSanitize
? this.getSanitizedConfigForStorage()
: this.config;
const configToPersist = this.removeHotkeysFromConfigForStorage(baseConfigToPersist);
await this.storage.save(
ConfigManager.CONFIG_KEY,
@@ -475,6 +486,11 @@ export class ConfigManager {
return { ...this.config };
}
private removeHotkeysFromConfigForStorage(config: AppConfig): AppConfig {
const { hotkeys: _hotkeys, ...configWithoutHotkeys } = config;
return configWithoutHotkeys as AppConfig;
}
/**
* Get a value from an object using dot notation
*/
@@ -49,7 +49,7 @@ vi.mock('../localLLMConfig', async () => {
};
});
describe('processUserMessage /welcome', () => {
describe('processUserMessage slash commands', () => {
beforeEach(() => {
configState.clear();
configState.set('general.llm_provider', 'local_browser');
@@ -132,6 +132,49 @@ describe('processUserMessage /welcome', () => {
expect(message?.content).toContain('welcome_local_llm.md');
});
it('fetches the hotkeys guide for /hotkeys', async () => {
const result = await processUserMessage('/hotkeys');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md'));
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
metadata: { command: 'hotkeys' },
});
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
it('supports /hotkey as an alias of /hotkeys', async () => {
const result = await processUserMessage('/hotkey');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md'));
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
metadata: { command: 'hotkeys' },
});
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
it('lists the new hotkeys commands for unknown slash commands', async () => {
const result = await processUserMessage('/unknown foo');
expect(storeState.setStatus).toHaveBeenCalledWith(
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
);
expect(result.pseudoAssistantResponse).toBe(
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
);
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
metadata: { command: 'unknown' },
});
});
it('blocks local-browser messages when the runtime is hard unsupported', async () => {
detectLocalLLMRuntimeSupportMock.mockReturnValue({
supported: false,
+29 -1
View File
@@ -148,9 +148,37 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
}
}
case '/hotkeys':
case '/hotkey': {
try {
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
}
const md = await resp.text();
return {
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: md,
metadata: { command: 'hotkeys' }
};
} catch (err) {
const fallback = 'Hotkeys guide is currently unavailable.';
return {
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: fallback,
metadata: { command: 'hotkeys', error: String(err) }
};
}
}
default: {
const { setStatus } = useProjectStore.getState();
const help = 'Available commands: /clear, /welcome, /help';
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
setStatus(`Unknown command: ${command}. ${help}`);
return {
displayUserMessage: false,
+8
View File
@@ -17,6 +17,14 @@ export const getRootNoteFromKeySignature = (keySignature: KeySignature): string
return match[1];
};
/**
* Resolves the chord-guide mode from a key signature.
* Chord guiding only supports major/minor quality and maps them to Ionian/Aeolian.
*/
export const getChordGuideModeFromKeySignature = (keySignature: KeySignature): 'ionian' | 'aeolian' => {
return keySignature.endsWith(' minor') ? 'aeolian' : 'ionian';
};
/**
* Converts a note name (without octave) to pitch class (0-11)
* @param noteName - Note name like "C", "C#", "Db", "F#"