Add terminal quick keys option and desktop toggle (#249)

* feat: add terminal quick keys toggle in OpenChamber UI

* feat: enhance mobile input handling in Terminal for Android devices
This commit is contained in:
Bohdan Triapitsyn
2026-01-30 14:48:53 +02:00
committed by GitHub
parent 1442e44e16
commit b74ac777f5
6 changed files with 74 additions and 10 deletions
@@ -83,7 +83,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile) // Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile)
const VisualSectionContent: React.FC = () => { const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing', 'cornerRadius', 'inputBarOffset']} />; return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
}; };
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode // Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode
@@ -8,6 +8,7 @@ import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn, getModifierLabel } from '@/lib/utils'; import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small'; import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { Switch } from '@/components/ui/switch';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { import {
@@ -73,7 +74,7 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
}, },
]; ];
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity'; export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys';
interface OpenChamberVisualSettingsProps { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -101,6 +102,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const diffViewMode = useUIStore(state => state.diffViewMode); const diffViewMode = useUIStore(state => state.diffViewMode);
const setDiffViewMode = useUIStore(state => state.setDiffViewMode); const setDiffViewMode = useUIStore(state => state.setDiffViewMode);
const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop);
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const { const {
@@ -252,6 +255,25 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
{shouldShow('terminalQuickKeys') && !isMobile && (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Show terminal optional key bar
</h3>
<p className="typography-ui text-muted-foreground">
Esc, Ctrl, arrows, Enter.
</p>
</div>
<Switch
checked={showTerminalQuickKeysOnDesktop}
onCheckedChange={setShowTerminalQuickKeysOnDesktop}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
{shouldShow('cornerRadius') && ( {shouldShow('cornerRadius') && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -824,6 +824,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
ref={containerRef} ref={containerRef}
className={cn('relative h-full w-full terminal-viewport-container', className)} className={cn('relative h-full w-full terminal-viewport-container', className)}
style={{ backgroundColor: theme.background }} style={{ backgroundColor: theme.background }}
onTouchStart={(event) => {
if (enableTouchScroll) {
const touch = event.touches?.[0];
focusHiddenInput(touch?.clientX, touch?.clientY);
}
}}
onClick={(event) => { onClick={(event) => {
if (enableTouchScroll) { if (enableTouchScroll) {
focusHiddenInput(event.clientX, event.clientY); focusHiddenInput(event.clientX, event.clientY);
@@ -867,13 +873,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
margin: 0, margin: 0,
outline: 'none', outline: 'none',
}} }}
onBeforeInput={(event) => {
const nativeEvent = event.nativeEvent as unknown as InputEvent | undefined;
const inputType = nativeEvent?.inputType ?? '';
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
// Android Chrome sometimes never commits text into the DOM value
// for invisible inputs. Use beforeinput as the primary path.
if (inputType === 'insertText' && data) {
event.preventDefault();
inputHandlerRef.current(data);
return;
}
if (inputType === 'insertLineBreak') {
event.preventDefault();
inputHandlerRef.current('\r');
return;
}
if (inputType === 'deleteContentBackward') {
event.preventDefault();
inputHandlerRef.current('\x7f');
}
}}
onInput={(event) => { onInput={(event) => {
const raw = String(event.currentTarget.value || ''); const raw = String(event.currentTarget.value || '');
if (!raw) { if (!raw) {
return; return;
} }
// iOS often inserts `\n` for Enter; the PTY expects CR. // Some mobile keyboards (iOS esp.) insert `\n` for Enter; PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r'); const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value); inputHandlerRef.current(value);
event.currentTarget.value = ''; event.currentTarget.value = '';
@@ -79,6 +79,8 @@ export const TerminalView: React.FC = () => {
const { currentTheme } = useThemeSystem(); const { currentTheme } = useThemeSystem();
const { monoFont } = useFontPreferences(); const { monoFont } = useFontPreferences();
const { isMobile, hasTouchInput } = useDeviceInfo(); const { isMobile, hasTouchInput } = useDeviceInfo();
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
const { currentSessionId, newSessionDraft } = useSessionStore(); const { currentSessionId, newSessionDraft } = useSessionStore();
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
@@ -138,10 +140,10 @@ export const TerminalView: React.FC = () => {
}, [effectiveDirectory]); }, [effectiveDirectory]);
React.useEffect(() => { React.useEffect(() => {
if (!isMobile && activeModifier !== null) { if (!showQuickKeys && activeModifier !== null) {
setActiveModifier(null); setActiveModifier(null);
} }
}, [isMobile, activeModifier, setActiveModifier]); }, [showQuickKeys, activeModifier, setActiveModifier]);
React.useEffect(() => { React.useEffect(() => {
if (!terminalSessionId && activeModifier !== null) { if (!terminalSessionId && activeModifier !== null) {
@@ -492,7 +494,7 @@ export const TerminalView: React.FC = () => {
); );
React.useEffect(() => { React.useEffect(() => {
if (!isMobile || !activeModifier || !terminalSessionId) { if (!showQuickKeys || !activeModifier || !terminalSessionId) {
return; return;
} }
@@ -565,7 +567,7 @@ export const TerminalView: React.FC = () => {
activeModifier, activeModifier,
handleMobileKeyPress, handleMobileKeyPress,
handleViewportInput, handleViewportInput,
isMobile, showQuickKeys,
setActiveModifier, setActiveModifier,
terminalSessionId, terminalSessionId,
]); ]);
@@ -693,7 +695,7 @@ export const TerminalView: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
{isMobile ? ( {showQuickKeys ? (
<div className="mt-2 flex flex-wrap items-center gap-1"> <div className="mt-2 flex flex-wrap items-center gap-1">
<Button <Button
type="button" type="button"
+10
View File
@@ -69,6 +69,8 @@ interface UIStore {
notificationMode: 'always' | 'hidden-only'; notificationMode: 'always' | 'hidden-only';
notifyOnSubtasks: boolean; notifyOnSubtasks: boolean;
showTerminalQuickKeysOnDesktop: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void; setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
@@ -122,6 +124,7 @@ interface UIStore {
setImagePreviewOpen: (open: boolean) => void; setImagePreviewOpen: (open: boolean) => void;
setNativeNotificationsEnabled: (value: boolean) => void; setNativeNotificationsEnabled: (value: boolean) => void;
setNotificationMode: (mode: 'always' | 'hidden-only') => void; setNotificationMode: (mode: 'always' | 'hidden-only') => void;
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void; setNotifyOnSubtasks: (value: boolean) => void;
openMultiRunLauncher: () => void; openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void; openMultiRunLauncherWithPrompt: (prompt: string) => void;
@@ -180,6 +183,8 @@ export const useUIStore = create<UIStore>()(
notificationMode: 'hidden-only', notificationMode: 'hidden-only',
notifyOnSubtasks: true, notifyOnSubtasks: true,
showTerminalQuickKeysOnDesktop: false,
setTheme: (theme) => { setTheme: (theme) => {
set({ theme }); set({ theme });
get().applyTheme(); get().applyTheme();
@@ -597,6 +602,10 @@ export const useUIStore = create<UIStore>()(
set({ notificationMode: mode }); set({ notificationMode: mode });
}, },
setShowTerminalQuickKeysOnDesktop: (value) => {
set({ showTerminalQuickKeysOnDesktop: value });
},
setNotifyOnSubtasks: (value) => { setNotifyOnSubtasks: (value) => {
set({ notifyOnSubtasks: value }); set({ notifyOnSubtasks: value });
}, },
@@ -634,6 +643,7 @@ export const useUIStore = create<UIStore>()(
diffViewMode: state.diffViewMode, diffViewMode: state.diffViewMode,
nativeNotificationsEnabled: state.nativeNotificationsEnabled, nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode, notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
notifyOnSubtasks: state.notifyOnSubtasks, notifyOnSubtasks: state.notifyOnSubtasks,
}) })
} }
+2 -2
View File
@@ -13,8 +13,8 @@
}, },
"scripts": { "scripts": {
"dev": "bun run build:watch", "dev": "bun run build:watch",
"dev:server": "node server/index.js --port 3001", "dev:server": "bun server/index.js --port 3001",
"dev:server:watch": "nodemon --watch server --ext js --exec \"node server/index.js --port 3001\"", "dev:server:watch": "nodemon --watch server --ext js --exec \"bun server/index.js --port 3001\"",
"build": "vite build", "build": "vite build",
"build:watch": "vite build --watch", "build:watch": "vite build --watch",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",