feat(chat): add arrow key navigation for thinking mode in model selector (#769)
* feat(chat): add arrow key navigation for thinking mode in model selector Adds keyboard arrow key navigation for thinking mode selection within the model picker, enabling a fully keyboard-driven workflow for model and variant selection. Changes: - Model Selector: Added ←/→ arrow key handling to cycle thinking variants while picker is open - Introduced transient state to track per-model variant selections during picker lifecycle - Updated metadata display logic to show thinking mode only after adjustment with arrow keys - Added contextual footer hints that appear only when highlighted model supports thinking variants - Modified Enter key behavior to apply both selected model and pending thinking variant together - Help Dialog: Added in-picker navigation shortcuts (↑↓ navigate, ←→ adjust thinking) - Documentation: Updated CHANGELOG.md files with feature entry Behavior: - Navigate models with ↑↓, adjust thinking with ←→, press Enter to apply both - Footer hints update dynamically based on highlighted model capabilities - Thinking display persists only for models adjusted with arrow keys - No-op for models without variants (clean, no feedback) Testing: - Type-check, lint, and build all pass - Manual testing verified on dev server
This commit is contained in:
@@ -1279,7 +1279,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
variant = 'assistant',
|
variant = 'assistant',
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
}) => {
|
}) => {
|
||||||
const { files, editor, runtime } = useRuntimeAPIs();
|
const { editor, runtime } = useRuntimeAPIs();
|
||||||
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||||
@@ -1354,7 +1354,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
onShowPopup,
|
onShowPopup,
|
||||||
allowMermaidWheelZoom = false,
|
allowMermaidWheelZoom = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { files, editor, runtime } = useRuntimeAPIs();
|
const { editor, runtime } = useRuntimeAPIs();
|
||||||
const renderedContent = React.useMemo(
|
const renderedContent = React.useMemo(
|
||||||
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
||||||
[content, stripFrontmatter],
|
[content, stripFrontmatter],
|
||||||
|
|||||||
@@ -413,6 +413,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
|
const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
|
||||||
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
|
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
|
||||||
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const [pendingThinkingVariants, setPendingThinkingVariants] = React.useState<Map<string, string | undefined>>(new Map());
|
||||||
|
const [adjustedThinkingModels, setAdjustedThinkingModels] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeMobilePanel === 'model') {
|
if (activeMobilePanel === 'model') {
|
||||||
@@ -441,6 +443,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
if (!isModelSelectorOpen) {
|
if (!isModelSelectorOpen) {
|
||||||
setDesktopModelQuery('');
|
setDesktopModelQuery('');
|
||||||
setModelSelectedIndex(0);
|
setModelSelectedIndex(0);
|
||||||
|
setPendingThinkingVariants(new Map());
|
||||||
|
setAdjustedThinkingModels(new Set());
|
||||||
|
|
||||||
// Restore focus to chat input when model selector closes
|
// Restore focus to chat input when model selector closes
|
||||||
if (wasOpen && !isCompact) {
|
if (wasOpen && !isCompact) {
|
||||||
@@ -1887,12 +1891,32 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
|
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
|
||||||
|
|
||||||
// Build animated metadata slides for desktop
|
// Check if model supports thinking variants - variants are on the model object, not metadata
|
||||||
|
const modelVariants = (model as { variants?: Record<string, unknown> } | undefined)?.variants;
|
||||||
|
const hasThinkingVariants = modelVariants && Object.keys(modelVariants).length > 0;
|
||||||
|
const mapKey = `${providerID}:${modelID}`;
|
||||||
|
const wasAdjusted = adjustedThinkingModels.has(mapKey);
|
||||||
|
const pendingVariant = pendingThinkingVariants.get(mapKey);
|
||||||
|
const effectiveVariant = pendingVariant ?? (isSelected ? currentVariant : undefined);
|
||||||
|
|
||||||
|
// Build thinking variant display - only show for models that were adjusted with arrow keys
|
||||||
|
let thinkingDisplay: React.ReactNode = null;
|
||||||
|
if (hasThinkingVariants && wasAdjusted && (isHighlighted || isSelected)) {
|
||||||
|
const displayLabel = effectiveVariant
|
||||||
|
? effectiveVariant.charAt(0).toUpperCase() + effectiveVariant.slice(1)
|
||||||
|
: 'Default';
|
||||||
|
thinkingDisplay = (
|
||||||
|
<span key="thinking" className="typography-micro text-muted-foreground whitespace-nowrap">
|
||||||
|
Thinking: {displayLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build animated metadata slides for desktop (price/capabilities) - only shown when not showing thinking
|
||||||
const priceText = formatCompactPrice(metadata);
|
const priceText = formatCompactPrice(metadata);
|
||||||
const hasPrice = priceText !== null;
|
const hasPrice = priceText !== null;
|
||||||
const hasCapabilities = indicatorIcons.length > 0;
|
const hasCapabilities = indicatorIcons.length > 0;
|
||||||
|
|
||||||
// Build slides array: price first, then capabilities
|
|
||||||
const slides: React.ReactNode[] = [];
|
const slides: React.ReactNode[] = [];
|
||||||
if (hasPrice) {
|
if (hasPrice) {
|
||||||
slides.push(
|
slides.push(
|
||||||
@@ -1919,9 +1943,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate metadata in interactive desktop-style pickers (web/desktop), keep VS Code static.
|
|
||||||
const supportsRotatingMetadata = !isVSCodeRuntime;
|
const supportsRotatingMetadata = !isVSCodeRuntime;
|
||||||
const shouldAnimate = supportsRotatingMetadata && slides.length > 1 && (isHighlighted || isSelected);
|
const shouldShowThinking = hasThinkingVariants && wasAdjusted;
|
||||||
|
const shouldAnimate = supportsRotatingMetadata && slides.length > 1 && (isHighlighted || isSelected) && !shouldShowThinking;
|
||||||
const staticSlideIndex = !supportsRotatingMetadata && hasCapabilities && hasPrice ? 1 : 0;
|
const staticSlideIndex = !supportsRotatingMetadata && hasCapabilities && hasPrice ? 1 : 0;
|
||||||
const staticMetadataSlide = slides[staticSlideIndex];
|
const staticMetadataSlide = slides[staticSlideIndex];
|
||||||
|
|
||||||
@@ -1950,8 +1974,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Metadata slot: animated TextLoop for desktop highlighted/selected rows, static otherwise */}
|
{/* Metadata slot: thinking variant for adjusted models, otherwise price/capabilities carousel */}
|
||||||
{slides.length > 0 && (
|
{shouldShowThinking && (isHighlighted || isSelected) ? (
|
||||||
|
<div className="flex w-[140px] justify-end items-center">
|
||||||
|
{thinkingDisplay}
|
||||||
|
</div>
|
||||||
|
) : slides.length > 0 ? (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"items-center",
|
"items-center",
|
||||||
shouldAnimate ? "flex w-[140px] justify-end" : ((isHighlighted || isSelected) ? "flex" : "hidden group-hover:flex")
|
shouldAnimate ? "flex w-[140px] justify-end" : ((isHighlighted || isSelected) ? "flex" : "hidden group-hover:flex")
|
||||||
@@ -1967,7 +1995,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<RiCheckLine className="h-4 w-4 text-primary" />
|
<RiCheckLine className="h-4 w-4 text-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -2071,6 +2099,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
const totalItems = flatModelList.length;
|
const totalItems = flatModelList.length;
|
||||||
|
|
||||||
|
// Check if currently highlighted model supports thinking variants
|
||||||
|
const highlightedItem = flatModelList[modelSelectedIndex];
|
||||||
|
const highlightedSupportsThinking = highlightedItem ? (() => {
|
||||||
|
const modelVariants = (highlightedItem.model as { variants?: Record<string, unknown> } | undefined)?.variants;
|
||||||
|
return modelVariants && Object.keys(modelVariants).length > 0;
|
||||||
|
})() : false;
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleModelKeyDown = (e: React.KeyboardEvent) => {
|
const handleModelKeyDown = (e: React.KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -2091,11 +2126,55 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const prevIndex = (modelSelectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
|
const prevIndex = (modelSelectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
|
||||||
modelItemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
modelItemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}, 0);
|
}, 0);
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selectedItem = flatModelList[modelSelectedIndex];
|
||||||
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
const { providerID, modelID, model } = selectedItem;
|
||||||
|
const modelVariants = (model as { variants?: Record<string, unknown> } | undefined)?.variants;
|
||||||
|
if (!modelVariants) return;
|
||||||
|
|
||||||
|
const variantKeys = Object.keys(modelVariants);
|
||||||
|
if (variantKeys.length === 0) return;
|
||||||
|
|
||||||
|
const mapKey = `${providerID}:${modelID}`;
|
||||||
|
const currentPending = pendingThinkingVariants.get(mapKey);
|
||||||
|
const activeModelVariant = currentPending ?? (currentProviderId === providerID && currentModelId === modelID ? currentVariant : undefined);
|
||||||
|
|
||||||
|
const variantsWithDefault: Array<string | undefined> = [undefined, ...variantKeys];
|
||||||
|
const currentVariantIndex = variantsWithDefault.indexOf(activeModelVariant);
|
||||||
|
const safeCurrentIndex = currentVariantIndex >= 0 ? currentVariantIndex : 0;
|
||||||
|
const direction = e.key === 'ArrowRight' ? 1 : -1;
|
||||||
|
const nextVariantIndex = (safeCurrentIndex + direction + variantsWithDefault.length) % variantsWithDefault.length;
|
||||||
|
const nextVariant = variantsWithDefault[nextVariantIndex];
|
||||||
|
|
||||||
|
setPendingThinkingVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(mapKey, nextVariant);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAdjustedThinkingModels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(mapKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const selectedItem = flatModelList[modelSelectedIndex];
|
const selectedItem = flatModelList[modelSelectedIndex];
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID);
|
const { providerID, modelID } = selectedItem;
|
||||||
|
const mapKey = `${providerID}:${modelID}`;
|
||||||
|
const pendingVariant = pendingThinkingVariants.get(mapKey);
|
||||||
|
const wasAdjusted = adjustedThinkingModels.has(mapKey);
|
||||||
|
|
||||||
|
handleProviderAndModelChange(providerID, modelID);
|
||||||
|
|
||||||
|
if (wasAdjusted) {
|
||||||
|
setTimeout(() => {
|
||||||
|
handleVariantSelect(pendingVariant);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2292,7 +2371,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
{/* Keyboard hints footer */}
|
{/* Keyboard hints footer */}
|
||||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||||
↑↓ navigate • Enter select • Esc close
|
↑↓ navigate{highlightedSupportsThinking ? ' • ←→ thinking' : ''} • Enter select • Esc close
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -88,9 +88,19 @@ export const HelpDialog: React.FC = () => {
|
|||||||
icon: RiAiGenerate2,
|
icon: RiAiGenerate2,
|
||||||
keys: '',
|
keys: '',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
keys: ["↑↓"],
|
||||||
|
description: "Navigate Models (in picker)",
|
||||||
|
icon: RiAiGenerate2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ["←→"],
|
||||||
|
description: "Adjust Thinking Mode (in picker, when supported)",
|
||||||
|
icon: RiBrainAi3Line,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cycle_thinking_variant',
|
id: 'cycle_thinking_variant',
|
||||||
description: "Cycle Thinking Variant",
|
description: "Cycle Thinking Variant (global shortcut)",
|
||||||
icon: RiBrainAi3Line,
|
icon: RiBrainAi3Line,
|
||||||
keys: '',
|
keys: '',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user