ModeSelector
Standalone mode selector: agent mode, plan mode, or any custom set. Bring your own icons or omit them. trigger="icon" shrinks the button to the icon of the current mode, the size of the neighbouring toolbar icons, and leaves the names and descriptions to the menu — the accessible name and the tooltip still say which mode is on, and a dot marks a mode other than the first. A mode without an icon keeps its name on the button. With a single mode the selector renders a non-interactive label.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Uncontrolled
Inside InputBar
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
input/ModeSelector.tsx
import React, { memo, useCallback, useState } from 'react';
import { Badge, UnstyledButton } from '@mantine/core';
import { IconCheck, IconChevronDown } from '@tabler/icons-react';
import { cx } from '../utils/cx';
import { InputPopover } from './InputPopover';
import classes from './ModeSelector.module.css';
export type ModeOption = {
id: string;
label: string;
/** Icon component, receives `className` for sizing */
icon?: React.ComponentType<{ className?: string }>;
description?: string;
/** Short mark after the label, for example `Default` */
badge?: string;
};
export interface ModeSelectorLabels {
/** Accessible label of the trigger, `Select mode` by default */
trigger: string;
/** Heading of the open menu, empty by default and then not shown */
title: string;
}
export const DEFAULT_MODE_SELECTOR_LABELS: ModeSelectorLabels = {
trigger: 'Select mode',
title: '',
};
export interface ModeSelectorProps {
modes: ModeOption[];
/** Controlled selected mode id */
value?: string;
/** Initial selected mode id in uncontrolled mode */
defaultValue?: string;
onChange?: (modeId: string) => void;
/** Shows the digits `1`…`N` next to the modes and picks a mode by its digit while the menu is open, `false` by default */
shortcuts?: boolean;
/** Overrides of the default English labels */
labels?: Partial<ModeSelectorLabels>;
/**
* Shape of the button: `label` shows the icon and the name of the mode, `icon` only the icon of
* the mode, the size of the neighbouring icon buttons. `label` by default; a mode without an icon
* keeps its name either way.
*/
trigger?: 'label' | 'icon';
/** Distance between the button and the panel in px, `8` by default */
sideOffset?: number;
className?: string;
style?: React.CSSProperties;
}
/** Toolbar dropdown for switching agent modes (for example Agent / Plan). Renders a static label when only one mode is given. */
export const ModeSelector = memo(function ModeSelector({
modes,
value,
defaultValue,
onChange,
shortcuts = false,
labels: labelsProp,
trigger: triggerVariant = 'label',
sideOffset,
className,
style,
}: ModeSelectorProps) {
const labels = { ...DEFAULT_MODE_SELECTOR_LABELS, ...labelsProp };
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState(defaultValue);
const activeId = isControlled ? value : internalValue;
const activeMode = modes.find((m) => m.id === activeId) ?? modes[0];
const [open, setOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
if (!isControlled) {
setInternalValue(id);
}
onChange?.(id);
setOpen(false);
},
[isControlled, onChange]
);
const pickByDigit = (event: React.KeyboardEvent<HTMLElement>) => {
const { key, metaKey, ctrlKey, altKey, repeat, isComposing } = event.nativeEvent;
if (!open || !shortcuts || metaKey || ctrlKey || altKey || repeat || isComposing) {
return;
}
const mode = /^[1-9]$/.test(key) ? modes[Number(key) - 1] : undefined;
if (mode) {
event.preventDefault();
handleSelect(mode.id);
}
};
if (modes.length === 0) {
return null;
}
const ActiveIcon = activeMode?.icon;
const hasMultiple = modes.length > 1;
const compact = triggerVariant === 'icon' && Boolean(ActiveIcon);
const changed = compact && activeMode?.id !== modes[0]?.id;
const trigger = (
<UnstyledButton
component={hasMultiple ? 'button' : 'span'}
className={cx(classes.trigger, className)}
style={style}
data-compact={compact || undefined}
data-static={!hasMultiple || undefined}
title={compact ? activeMode?.label : undefined}
aria-label={
hasMultiple
? compact
? `${labels.trigger}: ${activeMode?.label}`
: labels.trigger
: undefined
}
onKeyDown={hasMultiple ? pickByDigit : undefined}
>
{ActiveIcon && <ActiveIcon className={classes.triggerIcon} />}
{!compact && <span className={classes.label}>{activeMode?.label}</span>}
{!compact && hasMultiple && <IconChevronDown size={12} className={classes.chevron} />}
{changed && <span className={classes.mark} aria-hidden="true" />}
</UnstyledButton>
);
if (!hasMultiple) {
return trigger;
}
return (
<InputPopover
open={open}
onOpenChange={setOpen}
side="top"
align="start"
sideOffset={sideOffset}
trigger={trigger}
>
{labels.title && <div className={classes.title}>{labels.title}</div>}
<div onKeyDown={pickByDigit} role="presentation">
{modes.map((mode, index) => {
const isActive = mode.id === activeMode?.id;
const Icon = mode.icon;
return (
<UnstyledButton
key={mode.id}
onClick={() => handleSelect(mode.id)}
className={classes.option}
data-active={isActive || undefined}
aria-keyshortcuts={shortcuts && index < 9 ? String(index + 1) : undefined}
>
{Icon && <Icon className={classes.optionIcon} />}
<span className={classes.optionText}>
<span className={classes.optionLabel}>
{mode.label}
{mode.badge && (
<Badge size="xs" variant="default" radius="sm" className={classes.badge}>
{mode.badge}
</Badge>
)}
</span>
{mode.description && (
<span className={classes.optionDescription}>{mode.description}</span>
)}
</span>
{isActive && <IconCheck size={14} className={classes.check} />}
{shortcuts && index < 9 && (
<kbd className={classes.shortcut} aria-hidden="true">
{index + 1}
</kbd>
)}
</UnstyledButton>
);
})}
</div>
</InputPopover>
);
});
ModeSelector.displayName = 'ModeSelector';
API reference
Prop
Type
Required
modes
ModeOption[]
Yes
value
string
No
Controlled selected mode id
defaultValue
string
No
Initial selected mode id in uncontrolled mode
onChange
(modeId: string) => void
No
shortcuts
boolean
No
Shows the digits `1`…`N` next to the modes and picks a mode by its digit while the menu is open, `false` by default
labels
Partial<ModeSelectorLabels>
No
Overrides of the default English labels
trigger
'label' | 'icon'
No
Shape of the button: `label` shows the icon and the name of the mode, `icon` only the icon of the mode, the size of the neighbouring icon buttons. `label` by default; a mode without an icon keeps its name either way.
sideOffset
number
No
Distance between the button and the panel in px, `8` by default
className
string
No
style
React.CSSProperties
No