feat(terminal): refactor runtime and add mobile workspace (#2280)

Replace the legacy terminal flow with a shared authenticated WebSocket
runtime used across web, desktop, relay, and mobile surfaces.

- introduce the v3 terminal protocol with scoped attachments, snapshots,
  ordered output, bounded replay history, reconnects, and explicit lifecycle
- harden PTY creation, restart, resize, close, force-kill, idle cleanup,
  shell selection, login mode, environment sanitization, and appearance sync
- add runtime-aware terminal APIs with relay authentication and Electron parity
- add a fullscreen mobile terminal workspace with touch scrolling,
  long-press selection, safe-area controls, quick keys, and Ctrl/Alt input
- add terminal selection attachments, preview detection, project actions,
  shell settings, and localized UI
- harden Ghostty rendering, resize recovery, Unicode handling, block
  characters, line height, and stale-row behavior
- remove the obsolete terminal SSE path and update reverse-proxy guidance
- expand terminal runtime, transport, input, selection, and store coverage
- avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
Bohdan Triapitsyn
2026-07-17 13:17:21 +03:00
committed by GitHub
parent f5b4a267c0
commit d4a8c4d2e1
103 changed files with 4085 additions and 4496 deletions
@@ -139,6 +139,8 @@ const VisualSectionContent: React.FC = () => {
'inputBarOffset',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
'reportUsage',
]} />;
};
@@ -33,6 +33,10 @@ import {
setDirectoryShowHidden,
useDirectoryShowHidden,
} from '@/lib/directoryShowHidden';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { TerminalShellOption } from '@/lib/api/types';
import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
interface Option<T extends string> {
id: T;
@@ -245,7 +249,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -256,6 +260,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const { locale, locales, setLocale, label, t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
const { isMobile } = useDeviceInfo();
const { terminal } = useRuntimeAPIs();
const { browserTab } = usePwaDetection();
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
@@ -297,6 +302,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setFontSize = useUIStore(state => state.setFontSize);
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const setTerminalFontSize = useUIStore(state => state.setTerminalFontSize);
const terminalShell = useUIStore(state => state.terminalShell);
const setTerminalShell = useUIStore(state => state.setTerminalShell);
const terminalLoginShells = useUIStore(state => state.terminalLoginShells);
const setTerminalLoginShells = useUIStore(state => state.setTerminalLoginShells);
const editorFontSize = useUIStore(state => state.editorFontSize);
const setEditorFontSize = useUIStore(state => state.setEditorFontSize);
const uiFont = useUIStore(state => state.uiFont);
@@ -571,7 +580,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
? hasLocalizationSettings
: (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar');
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar');
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
@@ -597,6 +606,41 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent();
const showTerminalShellSetting = (shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode;
const [availableTerminalShells, setAvailableTerminalShells] = React.useState<TerminalShellOption[]>([]);
const [terminalShellRuntimeEpoch, setTerminalShellRuntimeEpoch] = React.useState(0);
React.useEffect(() => subscribeRuntimeEndpointChanged(() => {
setAvailableTerminalShells([]);
setTerminalShellRuntimeEpoch((epoch) => epoch + 1);
}), []);
React.useEffect(() => {
let cancelled = false;
if (!showTerminalShellSetting || !terminal.listShells) return;
void terminal.listShells()
.then((shells) => {
if (!cancelled) setAvailableTerminalShells(shells);
})
.catch(() => {
if (!cancelled) setAvailableTerminalShells([]);
});
return () => {
cancelled = true;
};
}, [showTerminalShellSetting, terminal, terminalShellRuntimeEpoch]);
const terminalShellOptions = React.useMemo(() => {
const explicitShells = availableTerminalShells.filter((shell) => shell.id !== 'auto');
if (terminalShell === 'auto' || explicitShells.some((shell) => shell.id === terminalShell)) {
return explicitShells;
}
return [{ id: terminalShell, name: terminalShell, supportsLogin: false }, ...explicitShells];
}, [availableTerminalShells, terminalShell]);
const terminalShellSupportsLogin = availableTerminalShells.find((shell) => shell.id === terminalShell)?.supportsLogin === true;
const terminalLoginShellEnabled = terminalLoginShells.includes(terminalShell);
const setTerminalLoginShellEnabled = (enabled: boolean) => {
setTerminalLoginShells(enabled
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell));
};
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
@@ -1260,8 +1304,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setTerminalFontSize(13)}
disabled={terminalFontSize === 13}
onClick={() => setTerminalFontSize(14)}
disabled={terminalFontSize === 14}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.visual.actions.resetTerminalFontSizeAria')}
title={t('settings.common.actions.reset')}
@@ -1478,6 +1522,52 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
</div>
)}
{showTerminalShellSetting && (
<div data-settings-item="appearance.terminal-shell" className="grid grid-cols-1 gap-y-1 py-1.5 sm:grid-cols-[14rem_minmax(0,1fr)] sm:gap-x-8">
<span className="typography-ui-label text-foreground">
{t('settings.openchamber.visual.field.terminalShell')}
</span>
<Select value={terminalShell} onValueChange={(value) => { if (isTerminalShell(value)) setTerminalShell(value); }}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.terminalShellAria')} className="w-[13rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.openchamber.visual.option.terminalShell.auto')}</SelectItem>
{terminalShellOptions.map((shell) => (
<SelectItem key={shell.id} value={shell.id}>{shell.name}</SelectItem>
))}
</SelectContent>
</Select>
<span className="max-w-[26rem] typography-meta text-muted-foreground sm:col-start-2">
{t('settings.openchamber.visual.field.terminalShellHint')}
</span>
{terminalShellSupportsLogin && (
<div
data-settings-item="appearance.terminal-login-shell"
className="group flex cursor-pointer items-center gap-2 pt-1 sm:col-start-2"
role="button"
tabIndex={0}
aria-pressed={terminalLoginShellEnabled}
onClick={() => setTerminalLoginShellEnabled(!terminalLoginShellEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setTerminalLoginShellEnabled(!terminalLoginShellEnabled);
}
}}
>
<Checkbox
checked={terminalLoginShellEnabled}
onChange={setTerminalLoginShellEnabled}
ariaLabel={t('settings.openchamber.visual.field.terminalLoginShell')}
/>
<span className="typography-ui-label text-foreground">
{t('settings.openchamber.visual.field.terminalLoginShell')}
</span>
</div>
)}
</div>
)}
</section>
</div>
)}