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
-28
View File
@@ -19,7 +19,6 @@ Use this guide when running OpenChamber behind Nginx, Nginx Proxy Manager, Caddy
- `/api/global/event`
- `/api/notifications/stream`
- `/api/openchamber/events`
- `/api/terminal/:sessionId/stream`
- Large request bodies for attachments and file operations
- Long-lived read timeouts for live streams and terminal sessions
@@ -104,19 +103,6 @@ location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ {
proxy_send_timeout 3600s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
@@ -239,20 +225,6 @@ location = /api/openchamber/events {
proxy_connect_timeout 30s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering "no" always;
add_header Cache-Control "no-cache, no-transform" always;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 3600s;
+1 -1
View File
@@ -23,7 +23,7 @@
"scripts": {
"dev": "node ./scripts/dev-web-hmr.mjs",
"oc-dev": "node scripts/oc-dev.mjs",
"build": "bun run --filter '*' build",
"build": "bun run --sequential --filter '!@openchamber/mobile' build && bun run --cwd packages/mobile build:assets",
"build:web": "bun run --cwd packages/web build",
"build:ui": "bun run --cwd packages/ui build",
"build:electron": "bun run --cwd packages/electron build",
+2
View File
@@ -11,12 +11,14 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h
- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`.
- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior.
- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection.
- The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text.
## Commands
Run these from `packages/mobile`, or use the root `mobile:*` aliases.
- `bun run build`: builds `packages/web` and prepares mobile web assets.
- `bun run build:assets`: prepares mobile assets from an existing `packages/web/dist` build; the root workspace build uses this to avoid rebuilding web.
- `bun run sync`: prepares assets and runs `cap sync`.
- `bun run add:ios`: creates the native iOS project.
- `bun run add:android`: creates the native Android project.
+2 -1
View File
@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs",
"build": "bun run --cwd ../web build && bun run build:assets",
"build:assets": "node scripts/prepare-web-assets.mjs",
"sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"",
"add:ios": "cap add ios",
"add:android": "cap add android",
+24 -1
View File
@@ -13,6 +13,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ChatView } from '@/components/views/ChatView';
import { SettingsView } from '@/components/views/SettingsView';
import { TerminalView } from '@/components/views/TerminalView';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
@@ -658,7 +659,7 @@ const getProjectLabel = (path: string): string => {
};
type OverflowItem = {
key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings';
key: 'files' | 'changes' | 'terminal' | 'mcp' | 'instances' | 'update' | 'settings';
icon?: IconName;
iconNode?: React.ReactNode;
label: string;
@@ -2064,6 +2065,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
const [filesOpen, setFilesOpen] = React.useState(false);
const [changesOpen, setChangesOpen] = React.useState(false);
const [terminalOpen, setTerminalOpen] = React.useState(false);
const [mcpOpen, setMcpOpen] = React.useState(false);
const [instancesOpen, setInstancesOpen] = React.useState(false);
const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false);
@@ -2340,6 +2342,12 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
},
);
}
items.push({
key: 'terminal',
icon: 'terminal',
label: t('mobile.menu.terminal'),
onSelect: () => setTerminalOpen(true),
});
items.push({
key: 'mcp',
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
@@ -2560,6 +2568,21 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
</MobileSurfaceShell>
) : null}
{terminalOpen ? (
<MobileSurfaceShell
open
onClose={() => setTerminalOpen(false)}
ariaLabel={t('mobile.menu.terminal')}
title={t('mobile.menu.terminal')}
disableSwipeDismiss
disableEscapeDismiss
>
<ErrorBoundary>
<TerminalView visible />
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
{mcpOpen ? (
<MobileOverlayPanel
open
+12 -5
View File
@@ -39,6 +39,8 @@ export type MobileSurfaceShellProps = {
onBack?: () => void;
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
disableSwipeDismiss?: boolean;
/** If true, leave Escape available to nested content instead of dismissing the surface. */
disableEscapeDismiss?: boolean;
/** If true, render only the drag handle and let the child render its own header. */
headerless?: boolean;
ariaLabel?: string;
@@ -53,6 +55,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
trailing,
onBack,
disableSwipeDismiss = false,
disableEscapeDismiss = false,
headerless = false,
ariaLabel,
children,
@@ -120,7 +123,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
};
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === 'Escape' && !disableEscapeDismiss) {
onCloseRef.current();
return;
}
@@ -154,7 +157,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
previousFocusRef.current?.focus?.({ preventScroll: true });
previousFocusRef.current = null;
};
}, [open]);
}, [disableEscapeDismiss, open]);
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (disableSwipeDismiss) return;
@@ -259,9 +262,13 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
onTouchEnd={handleDragEnd}
onTouchCancel={handleDragEnd}
>
<div className="flex items-center justify-center pt-2 pb-1">
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
</div>
{disableSwipeDismiss ? (
<div className="h-3" />
) : (
<div className="flex items-center justify-center pt-2 pb-1">
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
</div>
)}
{!headerless ? (
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
{leading}
@@ -7,8 +7,10 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
import { syncDesktopSettings } from '@/lib/persistence';
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
// to the new transport WITHOUT tearing down connection/session state or remounting
@@ -31,6 +33,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
}
disposeTerminalInputTransport();
useTerminalStore.getState().clearAll();
opencodeClient.reconnectToRuntimeBaseUrl();
useConfigStore.setState({
providers: [],
@@ -48,4 +51,5 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
queueMicrotask(() => void syncDesktopSettings());
};
@@ -534,8 +534,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
if (state === 'authenticated' && !hasResyncedRef.current) {
hasResyncedRef.current = true;
void (async () => {
await syncDesktopSettings();
await initializeAppearancePreferences();
await syncDesktopSettings();
await applyPersistedDirectoryPreferences();
})();
}
+25 -3
View File
@@ -1520,12 +1520,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
let previewConsole = 0;
let previewAnnotation = 0;
let review = 0;
let terminal = 0;
for (const draft of drafts) {
if (draft.source === 'preview-console') previewConsole += 1;
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
else if (draft.source === 'terminal') terminal += 1;
else review += 1;
}
return `${previewConsole}:${previewAnnotation}:${review}`;
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
},
[currentSessionId, newSessionDraftOpen]
)
@@ -1533,7 +1535,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 0
? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal')
: [];
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
if (!sessionKey) return;
@@ -1550,7 +1555,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!sessionKey) return;
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation') {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') {
removeInlineCommentDraft(sessionKey, draft.id);
}
}
@@ -2327,6 +2332,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
inputMode,
sendMessageOptions,
);
const restoreConsumedDrafts = () => {
if (sessionKey && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts);
}
};
if (typeof window === 'undefined') {
scrollToBottom?.();
@@ -2354,6 +2364,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const normalized = rawMessage.toLowerCase();
console.error('Message send failed:', rawMessage || error);
restoreConsumedDrafts();
const currentInput = textareaRef.current?.value ?? messageRef.current;
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
@@ -4650,6 +4661,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<AutoReviewBanner />
{hasDrafts && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{terminalContextDrafts.map((draft) => (
<div key={draft.id} className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1" title={draft.code}>
<Icon name="terminal" className="h-3.5 w-3.5" />
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })}
</span>
<button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => removeInlineCommentDraft(draft.sessionKey, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
))}
{reviewCount > 0 ? (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
@@ -14,6 +14,7 @@ import {
parseSkillHref,
} from '@/lib/messages/inlineMessageLinks';
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -31,7 +32,9 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]);
const textContent = terminalContextState.visibleText;
const [isExpanded, setIsExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
@@ -190,7 +193,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
});
}, [agentMention, openSkill, skillByName, textContent]);
if (!textContent || textContent.trim().length === 0) {
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
return null;
}
@@ -243,6 +246,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
plainTextContent
)}
</div>
{terminalContextState.contexts.length > 0 ? (
<div className="mt-2 space-y-1.5">
{terminalContextState.contexts.map((context, index) => (
<details key={`${context.terminalLabel}-${context.startLine}-${index}`} className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5 text-xs">
<summary className="cursor-pointer text-[var(--surface-mutedForeground)]">
{t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })}
</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[var(--surface-foreground)]">{context.text}</pre>
</details>
))}
</div>
) : null}
</div>
);
};
@@ -25,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
@@ -951,27 +952,37 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
// so when the proxy returns a 502 (upstream dev server is offline) the iframe
// would just render the raw JSON error body. Probe the proxy URL with a HEAD
// would just render the raw JSON error body. Probe the proxy URL with a GET
// request and surface a friendly overlay when the upstream is unreachable.
type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable';
const [upstreamState, setUpstreamState] = React.useState<UpstreamState>('unknown');
const upstreamProbeStartedAtRef = React.useRef<number>(0);
const upstreamProbeAttemptRef = React.useRef<number>(0);
const upstreamProbeKeyRef = React.useRef<string>('');
const proxyRecoveryAttemptedKeyRef = React.useRef<string>('');
const PREVIEW_STARTUP_GRACE_MS = 15_000;
React.useEffect(() => {
if (!proxySrc) {
setUpstreamState('unknown');
upstreamProbeKeyRef.current = '';
upstreamProbeStartedAtRef.current = 0;
upstreamProbeAttemptRef.current = 0;
return;
}
let cancelled = false;
if (!upstreamProbeStartedAtRef.current) {
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
if (upstreamProbeKeyRef.current !== proxyCacheKey) {
upstreamProbeKeyRef.current = proxyCacheKey;
upstreamProbeStartedAtRef.current = Date.now();
upstreamProbeAttemptRef.current = 0;
}
const scheduleRetry = (delay: number) => {
retryTimeout = setTimeout(() => {
if (!cancelled) bumpReload();
}, delay);
};
setUpstreamState('unknown');
void (async () => {
@@ -993,21 +1004,36 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
if (cancelled) return;
if (!response) {
// Network-level failure (e.g. server itself is down) — treat as unreachable.
setUpstreamState('unreachable');
scheduleRetry(5000);
return;
}
if (response.status === 403 || response.status === 404) {
const recoveryAction = getPreviewTargetRecoveryAction(
response.headers,
proxyRecoveryAttemptedKeyRef.current === proxyCacheKey,
);
if (recoveryAction !== 'none') {
previewProxyTargetCache.delete(proxyCacheKey);
setProxyState({ status: 'loading' });
bumpProxyRegistration();
if (recoveryAction === 'retry-registration') {
proxyRecoveryAttemptedKeyRef.current = proxyCacheKey;
setProxyState({ status: 'loading' });
bumpProxyRegistration();
} else {
const errorBody = await response.json().catch(() => ({}));
if (cancelled) return;
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
setProxyState({ status: 'error', message });
}
return;
}
// The proxy emits 502 when the upstream is unreachable. Anything else
// (including 4xx from the upstream) means the upstream answered.
if (response.status !== 502) {
proxyRecoveryAttemptedKeyRef.current = '';
setUpstreamState('reachable');
return;
}
@@ -1021,19 +1047,17 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
upstreamProbeAttemptRef.current += 1;
const attempt = upstreamProbeAttemptRef.current;
const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt)));
setTimeout(() => {
if (!cancelled) {
bumpReload();
}
}, delay).unref?.();
scheduleRetry(delay);
return;
}
setUpstreamState('unreachable');
scheduleRetry(5000);
})();
return () => {
cancelled = true;
if (retryTimeout) clearTimeout(retryTimeout);
};
}, [proxyCacheKey, proxySrc, reloadNonce]);
+4 -4
View File
@@ -64,7 +64,6 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
import { forceKillTerminal } from '@/lib/terminalApi';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
@@ -1776,7 +1775,8 @@ export const Header: React.FC<HeaderProps> = ({
}, [shortcutOverrides]);
useEffect(() => {
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) {
// Project actions may intentionally promote the terminal to the desktop main view.
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) {
setActiveMainTab('chat');
}
}, [activeMainTab, isMobile, setActiveMainTab]);
@@ -1831,7 +1831,7 @@ export const Header: React.FC<HeaderProps> = ({
try {
// Ensure preview/dev terminals don't linger.
await forceKillTerminal({});
await runtimeApis.terminal.forceKill?.({});
} catch {
// ignore
}
@@ -1856,7 +1856,7 @@ export const Header: React.FC<HeaderProps> = ({
setIsDevShutdownInFlight(false);
}
}
}, [isDevShutdownInFlight, setIsDesktopServicesOpen]);
}, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]);
const quotaDisplayTabs = React.useMemo(() => {
return [
@@ -16,6 +16,7 @@ import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
@@ -31,8 +32,9 @@ import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PlanView } from '@/components/views/PlanView';
// Heavy views loaded on-demand to reduce initial bundle parse time.
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time.
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
@@ -365,7 +367,7 @@ export const MainLayout: React.FC = () => {
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
return <React.Suspense fallback={null}><TerminalView /></React.Suspense>;
return <TerminalView />;
case 'files':
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
case 'context':
@@ -539,12 +541,10 @@ export const MainLayout: React.FC = () => {
<ContextPanel />
</div>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
{isBottomTerminalOpen ? (
<BottomTerminalDock isOpen={isBottomTerminalOpen && activeMainTab !== 'terminal'} isMobile={isMobile}>
{isBottomTerminalOpen && activeMainTab !== 'terminal' ? (
<ErrorBoundary>
<React.Suspense fallback={null}>
<TerminalView />
</React.Suspense>
<TerminalView />
</ErrorBoundary>
) : null}
</BottomTerminalDock>
@@ -15,6 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
@@ -31,7 +32,7 @@ import {
toProjectActionRunKey,
} from '@/lib/projectActions';
import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer';
import { connectTerminalStream } from '@/lib/terminalApi';
import { waitForTerminalExit } from '@/lib/projectActionTerminal';
type UrlWatchEntry = {
lastSeenChunkId: number | null;
@@ -40,12 +41,6 @@ type UrlWatchEntry = {
openInPreview: boolean;
};
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
};
interface ProjectActionsButtonProps {
projectRef: ProjectRef | null;
directory: string;
@@ -154,6 +149,7 @@ export const ProjectActionsButton = ({
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
@@ -161,13 +157,14 @@ export const ProjectActionsButton = ({
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const terminalShell = useUIStore((state) => state.terminalShell);
const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell));
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const terminalSessions = useTerminalStore((state) => state.sessions);
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
const setTabIconKey = useTerminalStore((state) => state.setTabIconKey);
@@ -187,6 +184,7 @@ export const ProjectActionsButton = ({
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
const streamCleanupByRunKeyRef = React.useRef<Record<string, () => void>>({});
const previewWaitTimeoutByRunKeyRef = React.useRef<Record<string, number>>({});
const startingRunKeysRef = React.useRef<Set<string>>(new Set());
const loadRequestIdRef = React.useRef(0);
const projectId = projectRef?.id ?? null;
@@ -311,79 +309,66 @@ export const ProjectActionsButton = ({
}, [actions, canUseAutoDiscover, selectedActionId]);
React.useEffect(() => {
for (const [key, entry] of Object.entries(projectActionRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
removeProjectActionRun(key);
}
}
}, [projectActionRuns, removeProjectActionRun, terminalSessions]);
React.useEffect(() => {
for (const [runKey, entry] of Object.entries(projectActionRuns)) {
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action) {
continue;
}
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) {
continue;
}
const nextChunks = tab.bufferChunks.filter((chunk) => {
if (watch.lastSeenChunkId === null) {
return true;
const monitorRuns = () => {
const terminalSessions = useTerminalStore.getState().sessions;
const currentRuns = useTerminalStore.getState().projectActionRuns;
for (const [runKey, entry] of Object.entries(currentRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
removeProjectActionRun(runKey);
continue;
}
return chunk.id > watch.lastSeenChunkId;
});
if (nextChunks.length === 0) {
continue;
}
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) continue;
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
if (nextChunks.length === 0) continue;
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
if (maybeUrl) {
watch.openedUrl = true;
if (watch.openInPreview) {
const run = projectActionRuns[runKey];
if (run) {
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
if (run.status === 'waiting-for-preview') {
updateProjectActionRunStatus(runKey, 'running');
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
if (maybeUrl) {
watch.openedUrl = true;
if (watch.openInPreview) {
const run = currentRuns[runKey];
if (run) {
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
if (run.status === 'waiting-for-preview') updateProjectActionRunStatus(runKey, 'running');
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
openContextPreview(run.directory, maybeUrl);
}
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
openContextPreview(run.directory, maybeUrl);
} else {
void openExternal(maybeUrl);
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
} else {
void openExternal(maybeUrl);
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!currentRuns[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
}
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
};
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!projectActionRuns[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
}
}
}, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]);
monitorRuns();
return useTerminalStore.subscribe((state, previousState) => {
if (state.sessions !== previousState.sessions) monitorRuns();
});
}, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]);
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => {
if (!normalizedDirectory) {
@@ -408,8 +393,8 @@ export const ProjectActionsButton = ({
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
setTabIconKey(normalizedDirectory, tabId, action.icon || 'play');
setActiveTab(normalizedDirectory, tabId);
if (options.revealTerminal !== false) {
setActiveTab(normalizedDirectory, tabId);
setBottomTerminalOpen(true);
setActiveMainTab('terminal');
}
@@ -447,6 +432,8 @@ export const ProjectActionsButton = ({
if (existingRun && existingRun.status === 'running') {
return;
}
if (startingRunKeysRef.current.has(runKey)) return;
startingRunKeysRef.current.add(runKey);
try {
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
@@ -471,16 +458,23 @@ export const ProjectActionsButton = ({
: action;
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID });
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal });
let activeSessionId = sessionId;
let createdSession = false;
if (!activeSessionId) {
setConnecting(normalizedDirectory, tabId, true);
try {
const created = await terminal.createSession({ cwd: normalizedDirectory });
const created = await terminal.createSession({
cwd: normalizedDirectory,
sessionId: tabId,
shell: terminalShell,
loginShell: terminalLoginShell,
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
terminalBackground: currentTheme.colors.surface.background,
terminalForeground: currentTheme.colors.syntax.base.foreground,
});
activeSessionId = created.sessionId;
createdSession = true;
setTabSessionId(normalizedDirectory, tabId, activeSessionId);
} finally {
setConnecting(normalizedDirectory, tabId, false);
@@ -491,18 +485,17 @@ export const ProjectActionsButton = ({
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (createdSession) {
await sleep(350);
}
if (discovered.id === AUTO_DISCOVER_ACTION_ID) {
streamCleanupByRunKeyRef.current[key]?.();
setConnecting(normalizedDirectory, tabId, true);
streamCleanupByRunKeyRef.current[key] = connectTerminalStream(
streamCleanupByRunKeyRef.current[key]?.();
setConnecting(normalizedDirectory, tabId, true);
const subscription = terminal.connect(
activeSessionId,
(event) => {
{ onEvent: (event) => {
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(normalizedDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
}
if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) {
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data);
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data, event.sequence, event.replayData);
}
if (event.type === 'exit') {
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
@@ -514,13 +507,16 @@ export const ProjectActionsButton = ({
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
delete previewWaitTimeoutByRunKeyRef.current[key];
}
},
() => {
}, onError: (_error, fatal) => {
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
},
{ maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 },
if (fatal) {
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
useTerminalStore.getState().setTabSessionId(normalizedDirectory, tabId, null);
useTerminalStore.getState().removeProjectActionRun(key);
}
} },
);
}
streamCleanupByRunKeyRef.current[key] = subscription.close;
const hasDesktopForwardSelection = discovered.autoOpenUrl === true
&& isDesktopShellApp
@@ -542,11 +538,27 @@ export const ProjectActionsButton = ({
delete previewWaitTimeoutByRunKeyRef.current[key];
if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) {
previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => {
useTerminalStore.getState().updateProjectActionRunStatus(key, 'running');
const store = useTerminalStore.getState();
const run = store.projectActionRuns[key];
store.updateProjectActionRunStatus(key, 'running');
if (run) {
store.setActiveTab(run.directory, run.tabId);
useUIStore.getState().setBottomTerminalOpen(true);
}
delete previewWaitTimeoutByRunKeyRef.current[key];
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
if (desktopForwardUrl) {
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
void openExternal(desktopForwardUrl);
@@ -565,15 +577,6 @@ export const ProjectActionsButton = ({
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false });
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
} catch (error) {
removeProjectActionRun(runKey);
delete urlWatchByRunKeyRef.current[runKey];
@@ -582,14 +585,21 @@ export const ProjectActionsButton = ({
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
} finally {
startingRunKeysRef.current.delete(runKey);
}
}, [
currentTheme.colors.surface.background,
currentTheme.colors.syntax.base.foreground,
currentTheme.metadata.variant,
desktopSshInstances,
getOrCreateActionTab,
allowMobile,
isMobile,
isDesktopShellApp,
normalizedDirectory,
terminalLoginShell,
terminalShell,
openExternal,
openContextPreview,
projectActionRuns,
@@ -613,22 +623,22 @@ export const ProjectActionsButton = ({
updateProjectActionRunStatus(runKey, 'stopping');
const exitPromise = waitForTerminalExit(terminal, activeRun.sessionId, 1000);
try {
await terminal.sendInput(activeRun.sessionId, '\x03');
} catch {
// noop
}
await new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
const exitObserved = await exitPromise;
const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs
.find((entry) => entry.id === activeRun.tabId);
const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId;
if (sessionStillSame) {
if (sessionStillSame && !exitObserved) {
if (typeof terminal.forceKill === 'function') {
try {
await terminal.forceKill({ sessionId: activeRun.sessionId });
@@ -699,6 +709,13 @@ export const ProjectActionsButton = ({
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
const previewAction = selectedAction ?? displayActions[0] ?? null;
const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(normalizedDirectory, previewAction.id)] : null;
const selectedRunPreviewUrl = useTerminalStore((state) => {
if (!previewRun) return null;
return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null;
});
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
return null;
}
@@ -716,9 +733,6 @@ export const ProjectActionsButton = ({
const selectedRunning = projectActionRuns[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview';
const selectedRunPreviewUrl = selectedRunning
? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null
: null;
const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl);
const handleOpenSelectedPreview = () => {
if (!selectedRunning || !selectedRunPreviewUrl) {
@@ -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>
)}
File diff suppressed because it is too large Load Diff
+218 -265
View File
@@ -16,79 +16,26 @@ import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { Icon } from "@/components/icon/Icon";
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { primeTerminalInputTransport } from '@/lib/terminalApi';
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
type Modifier = 'ctrl' | 'cmd';
type MobileKey =
| 'esc'
| 'tab'
| 'enter'
| 'arrow-up'
| 'arrow-down'
| 'arrow-left'
| 'arrow-right';
const BASE_KEY_SEQUENCES: Record<MobileKey, string> = {
esc: '\u001b',
tab: '\t',
enter: '\r',
'arrow-up': '\u001b[A',
'arrow-down': '\u001b[B',
'arrow-left': '\u001b[D',
'arrow-right': '\u001b[C',
type TerminalViewProps = {
visible?: boolean;
};
const MODIFIER_ARROW_SUFFIX: Record<Modifier, string> = {
ctrl: '5',
cmd: '3',
};
const STREAM_OPTIONS = {
retry: {
maxRetries: 3,
initialDelayMs: 500,
maxDelayMs: 8000,
},
connectionTimeoutMs: 10_000,
};
const REHYDRATED_STREAM_OPTIONS = {
retry: {
...STREAM_OPTIONS.retry,
initialDelayMs: 200,
maxDelayMs: 500,
},
connectionTimeoutMs: 1_500,
};
const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => {
if (modifier) {
switch (key) {
case 'arrow-up':
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}A`;
case 'arrow-down':
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}B`;
case 'arrow-right':
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}C`;
case 'arrow-left':
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}D`;
default:
break;
}
}
return BASE_KEY_SEQUENCES[key] ?? null;
};
export const TerminalView: React.FC = () => {
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const terminalAppearanceRef = React.useRef<{ themeMode: 'light' | 'dark'; terminalBackground: string; terminalForeground: string }>({ themeMode: 'dark', terminalBackground: '', terminalForeground: '' });
terminalAppearanceRef.current = { themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', terminalBackground: currentTheme.colors.surface.background, terminalForeground: currentTheme.colors.syntax.base.foreground };
const { monoFont } = useFontPreferences();
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const terminalShell = useUIStore(state => state.terminalShell);
const terminalLoginShell = useUIStore(state => state.terminalLoginShells.includes(state.terminalShell));
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
const { isMobile, isTablet, hasTouchOnlyPointer } = useDeviceInfo();
@@ -104,7 +51,7 @@ export const TerminalView: React.FC = () => {
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
const effectiveDirectory = useEffectiveDirectory() ?? null;
const terminalSessions = useTerminalStore((s) => s.sessions);
const directoryTerminalState = useTerminalStore((s) => effectiveDirectory ? s.sessions.get(effectiveDirectory) : undefined);
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
const createTab = useTerminalStore((s) => s.createTab);
@@ -114,16 +61,12 @@ export const TerminalView: React.FC = () => {
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
const setConnecting = useTerminalStore((s) => s.setConnecting);
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
const replaceBuffer = useTerminalStore((s) => s.replaceBuffer);
const setTabPreviewUrl = useTerminalStore((s) => s.setTabPreviewUrl);
const clearBuffer = useTerminalStore((s) => s.clearBuffer);
const addContextDraft = useInlineCommentDraftStore((s) => s.addDraft);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const directoryTerminalState = React.useMemo(() => {
if (!effectiveDirectory) return undefined;
return terminalSessions.get(effectiveDirectory);
}, [terminalSessions, effectiveDirectory]);
const activeTabId = React.useMemo(() => {
if (!directoryTerminalState) return null;
if (enableTabs) {
@@ -165,7 +108,7 @@ export const TerminalView: React.FC = () => {
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [viewportSizeVersion, setViewportSizeVersion] = React.useState(0);
const [hasViewportSize, setHasViewportSize] = React.useState(false);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -175,9 +118,6 @@ export const TerminalView: React.FC = () => {
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
const rehydratedSnapshotTakenRef = React.useRef(false);
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
const previewProbeGenerationRef = React.useRef(0);
@@ -205,43 +145,14 @@ export const TerminalView: React.FC = () => {
terminalControllerRef.current?.focus();
}, [useTouchTerminalInput]);
React.useEffect(() => {
if (!terminalHydrated) {
return;
}
if (rehydratedSnapshotTakenRef.current) {
return;
}
rehydratedSnapshotTakenRef.current = true;
const ids = new Set<string>();
for (const [, dirState] of useTerminalStore.getState().sessions.entries()) {
for (const tab of dirState.tabs) {
if (tab.terminalSessionId) {
ids.add(tab.terminalSessionId);
}
}
}
rehydratedTerminalIdsRef.current = ids;
}, [terminalHydrated]);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
const isTerminalActive = activeMainTab === 'terminal';
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
const isTerminalVisible = visible ?? (isTerminalActive || isBottomTerminalOpen);
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
React.useEffect(() => {
if (!isTerminalVisible || runtime.platform === 'vscode') {
return;
}
primeTerminalInputTransport();
}, [isTerminalVisible, runtime.platform]);
React.useEffect(() => {
if (isTerminalVisible) {
setHasOpenedTerminalViewport(true);
@@ -256,6 +167,11 @@ export const TerminalView: React.FC = () => {
terminalIdRef.current = terminalSessionId;
}, [terminalSessionId]);
React.useEffect(() => {
if (!terminalSessionId || !terminal.updateAppearance) return;
void terminal.updateAppearance(terminalSessionId, terminalAppearanceRef.current).catch(() => {});
}, [currentTheme.colors.surface.background, currentTheme.colors.syntax.base.foreground, currentTheme.metadata.variant, terminal, terminalSessionId]);
React.useEffect(() => {
activeTabIdRef.current = activeTabId;
resetTerminalPreviewScan();
@@ -337,8 +253,7 @@ export const TerminalView: React.FC = () => {
(
directory: string,
tabId: string,
terminalId: string,
streamOptions = STREAM_OPTIONS
terminalId: string
) => {
if (activeTerminalIdRef.current === terminalId) {
return;
@@ -358,21 +273,16 @@ export const TerminalView: React.FC = () => {
}
switch (event.type) {
case 'connected': {
case 'snapshot': {
setConnecting(directory, tabId, false);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
focusTerminalWhenWindowActive();
// After a reload, buffer is empty and a reused PTY can look "stuck"
// until the first output arrives. Nudge with a newline once.
if (nudgeOnConnectTerminalIdRef.current === terminalId) {
nudgeOnConnectTerminalIdRef.current = null;
void terminal.sendInput(terminalId, '\r').catch(() => {
// ignore
});
}
replaceBuffer(directory, tabId, event.data ?? '', event.sequence ?? 0);
scanTerminalPreviewOutput(directory, tabId, event.data ?? '');
if (event.status === 'exited') setTabLifecycle(directory, tabId, 'exited');
break;
}
case 'reconnecting': {
@@ -384,7 +294,7 @@ export const TerminalView: React.FC = () => {
}
case 'data': {
if (event.data) {
appendToBuffer(directory, tabId, event.data);
appendToBuffer(directory, tabId, event.data, event.sequence, event.replayData);
scanTerminalPreviewOutput(directory, tabId, event.data);
}
break;
@@ -412,7 +322,6 @@ export const TerminalView: React.FC = () => {
})
);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
setConnecting(directory, tabId, false);
setConnectionError(isActionTab ? null : t('terminalView.error.sessionEnded'));
setIsFatalError(false);
@@ -434,18 +343,28 @@ export const TerminalView: React.FC = () => {
}
setIsReconnectPending(false);
if (error.code === 'SESSION_NOT_FOUND') {
const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId);
if (!currentTab?.label?.startsWith('Action:')) {
setConnectionError(null);
setIsFatalError(false);
setConnecting(directory, tabId, false);
setTabSessionId(directory, tabId, null);
setTabLifecycle(directory, tabId, 'idle');
disconnectStream();
return;
}
}
setConnectionError(
t('terminalView.error.connectionFailed', { message: error.message })
);
setIsFatalError(true);
setConnecting(directory, tabId, false);
clearBuffer(directory, tabId);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
disconnectStream();
},
},
streamOptions
}
);
streamCleanupRef.current = () => {
@@ -455,7 +374,7 @@ export const TerminalView: React.FC = () => {
},
[
appendToBuffer,
clearBuffer,
replaceBuffer,
disconnectStream,
focusTerminalWhenWindowActive,
scanTerminalPreviewOutput,
@@ -488,7 +407,11 @@ export const TerminalView: React.FC = () => {
const directory = effectiveDirectory;
if (!directoryRef.current || directoryRef.current !== directory) return;
ensureDirectory(directory);
const existingState = useTerminalStore.getState().getDirectoryState(directory);
if (!existingState) {
ensureDirectory(directory);
return;
}
const state = useTerminalStore.getState().getDirectoryState(directory);
if (!state || state.tabs.length === 0) {
@@ -508,15 +431,6 @@ export const TerminalView: React.FC = () => {
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
const shouldNudgeExisting =
Boolean(terminalId) &&
rehydratedTerminalIdsRef.current.has(terminalId as string) &&
(tab?.bufferLength ?? 0) === 0 &&
(tab?.bufferChunks?.length ?? 0) === 0;
const isRehydratedSession =
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
if (!terminalId) {
if (terminalLifecycle === 'exited') {
setConnecting(directory, tabId, false);
@@ -540,8 +454,12 @@ export const TerminalView: React.FC = () => {
try {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: size?.cols,
rows: size?.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
});
const stillActive =
@@ -549,7 +467,8 @@ export const TerminalView: React.FC = () => {
directoryRef.current === directory &&
activeTabIdRef.current === tabId;
if (!stillActive) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab) {
try {
await terminal.close(session.sessionId);
} catch { /* ignored */ }
@@ -557,6 +476,7 @@ export const TerminalView: React.FC = () => {
}
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
terminalId = session.sessionId;
} catch (error) {
if (!cancelled) {
@@ -577,19 +497,7 @@ export const TerminalView: React.FC = () => {
terminalIdRef.current = terminalId;
if (isRehydratedSession) {
rehydratedTerminalIdsRef.current.delete(terminalId);
}
if (shouldNudgeExisting) {
nudgeOnConnectTerminalIdRef.current = terminalId;
}
startStream(
directory,
tabId,
terminalId,
isRehydratedSession ? REHYDRATED_STREAM_OPTIONS : STREAM_OPTIONS
);
startStream(directory, tabId, terminalId);
};
void ensureSession();
@@ -606,7 +514,7 @@ export const TerminalView: React.FC = () => {
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
viewportSizeVersion,
hasViewportSize,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -617,6 +525,8 @@ export const TerminalView: React.FC = () => {
disconnectStream,
t,
terminal,
terminalLoginShell,
terminalShell,
]);
React.useEffect(() => {
@@ -647,6 +557,8 @@ export const TerminalView: React.FC = () => {
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
: (state?.tabs[0]?.id ?? null);
if (!tabId) return;
const originalSessionId = state?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId ?? null;
if (!originalSessionId || !terminal.restartSession) return;
setIsRestarting(true);
setConnectionError(null);
@@ -654,21 +566,36 @@ export const TerminalView: React.FC = () => {
setIsReconnectPending(false);
disconnectStream();
clearBuffer(effectiveDirectory, tabId);
resetTerminalPreviewScan();
try {
await closeTab(effectiveDirectory, tabId);
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
if (owningTab?.terminalSessionId !== originalSessionId) return;
setTabSessionId(effectiveDirectory, tabId, restarted.sessionId);
setTabLifecycle(effectiveDirectory, tabId, 'running');
if (directoryRef.current !== effectiveDirectory || activeTabIdRef.current !== tabId) return;
terminalIdRef.current = restarted.sessionId;
startStream(effectiveDirectory, tabId, restarted.sessionId);
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
if (
owningTab?.terminalSessionId !== originalSessionId
|| directoryRef.current !== effectiveDirectory
|| activeTabIdRef.current !== tabId
) return;
setConnectionError(
error instanceof Error ? error.message : t('terminalView.error.restartFailed')
);
setIsFatalError(true);
setIsFatalError(false);
setIsReconnectPending(false);
terminalIdRef.current = originalSessionId;
startStream(effectiveDirectory, tabId, originalSessionId);
} finally {
setIsRestarting(false);
}
}, [activeTabId, clearBuffer, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, t]);
}, [activeTabId, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.
@@ -685,6 +612,22 @@ export const TerminalView: React.FC = () => {
disconnectStream();
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
const handleAttachSelection = React.useCallback(() => {
const selection = terminalControllerRef.current?.getSelection();
const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null);
if (!selection || !sessionKey || !activeTab) return;
addContextDraft({
sessionKey,
source: 'terminal',
fileLabel: activeTab.label,
startLine: selection.startLine,
endLine: selection.endLine,
code: selection.text,
language: activeTab.terminalSessionId ?? activeTab.id,
text: '',
});
}, [activeTab, addContextDraft, currentSessionId, newSessionDraft?.open]);
const handleSelectTab = React.useCallback(
(tabId: string) => {
if (!effectiveDirectory) return;
@@ -708,9 +651,13 @@ export const TerminalView: React.FC = () => {
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
void closeTab(effectiveDirectory, tabId);
const sessionId = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId;
void (async () => {
if (sessionId) await terminal.close(sessionId);
closeTab(effectiveDirectory, tabId);
})().catch((error) => setConnectionError(error instanceof Error ? error.message : t('terminalView.error.sessionEnded')));
},
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
[activeTabId, closeTab, disconnectStream, effectiveDirectory, t, terminal]
);
const handleViewportInput = React.useCallback(
@@ -723,18 +670,8 @@ export const TerminalView: React.FC = () => {
let modifierConsumed = false;
if (activeModifier && data.length > 0) {
const firstChar = data[0];
if (firstChar.length === 1 && /[a-zA-Z]/.test(firstChar)) {
const upper = firstChar.toUpperCase();
if (activeModifier === 'ctrl' || activeModifier === 'cmd') {
payload = String.fromCharCode(upper.charCodeAt(0) & 0b11111);
modifierConsumed = true;
}
}
if (!modifierConsumed) {
modifierConsumed = true;
}
payload = applyTerminalModifier(data, activeModifier);
modifierConsumed = true;
}
const terminalId = terminalIdRef.current;
@@ -759,9 +696,11 @@ export const TerminalView: React.FC = () => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
const previous = lastViewportSizeRef.current;
if (!previous || previous.cols !== cols || previous.rows !== rows) {
if (!previous) {
lastViewportSizeRef.current = { cols, rows };
if (!terminalIdRef.current) setHasViewportSize(true);
} else if (previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
setViewportSizeVersion((version) => version + 1);
}
if (!isTerminalVisibleRef.current) {
return;
@@ -785,7 +724,7 @@ export const TerminalView: React.FC = () => {
const handleMobileKeyPress = React.useCallback(
(key: MobileKey) => {
const sequence = getSequenceForKey(key, activeModifier);
const sequence = terminalSequenceForKey(key, activeModifier);
if (!sequence) {
return;
}
@@ -796,85 +735,48 @@ export const TerminalView: React.FC = () => {
[activeModifier, focusTerminalController, handleViewportInput, setActiveModifier]
);
React.useEffect(() => {
if (!showQuickKeys || !activeModifier || !terminalSessionId) {
const QUICK_KEY_MAP = React.useMemo<Record<string, MobileKey>>(() => ({
Tab: 'tab', Enter: 'enter', ArrowUp: 'arrow-up', ArrowDown: 'arrow-down',
ArrowLeft: 'arrow-left', ArrowRight: 'arrow-right', Escape: 'esc',
}), []);
const handleQuickKeyDown = React.useCallback((event: KeyboardEvent) => {
if (event.repeat) return;
const rawKey = event.key;
if (!rawKey || rawKey === 'Control' || rawKey === 'Meta' || rawKey === 'Alt' || rawKey === 'Shift') return;
const normalizedKey = rawKey.length === 1 ? rawKey.toLowerCase() : rawKey;
if (normalizedKey in QUICK_KEY_MAP) {
event.preventDefault();
event.stopPropagation();
handleMobileKeyPress(QUICK_KEY_MAP[normalizedKey]);
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
if (activeModifier !== 'ctrl') return;
const rawKey = event.key;
if (!rawKey) {
return;
}
const code = event.code ?? '';
const upperKey =
rawKey.length === 1 && /[a-zA-Z]/.test(rawKey)
? rawKey.toUpperCase()
: (code.startsWith('Key') && code.length === 4 ? code.slice(3).toUpperCase() : null);
if (rawKey === 'Control' || rawKey === 'Meta' || rawKey === 'Alt' || rawKey === 'Shift') {
return;
}
if (upperKey && upperKey.length === 1 && upperKey >= 'A' && upperKey <= 'Z') {
const controlCode = terminalControlCharacter(upperKey);
if (!controlCode) return;
event.preventDefault();
event.stopPropagation();
handleViewportInput(controlCode);
setActiveModifier(null);
focusTerminalController();
}
}, [activeModifier, focusTerminalController, handleMobileKeyPress, handleViewportInput, QUICK_KEY_MAP, setActiveModifier]);
const normalizedKey = rawKey.length === 1 ? rawKey.toLowerCase() : rawKey;
const code = event.code ?? '';
const upperFromCode =
code.startsWith('Key') && code.length === 4
? code.slice(3).toUpperCase()
: null;
const upperKey =
rawKey.length === 1 && /[a-zA-Z]/.test(rawKey)
? rawKey.toUpperCase()
: upperFromCode;
const toMobileKey: Record<string, MobileKey> = {
Tab: 'tab',
Enter: 'enter',
ArrowUp: 'arrow-up',
ArrowDown: 'arrow-down',
ArrowLeft: 'arrow-left',
ArrowRight: 'arrow-right',
Escape: 'esc',
tab: 'tab',
enter: 'enter',
arrowup: 'arrow-up',
arrowdown: 'arrow-down',
arrowleft: 'arrow-left',
arrowright: 'arrow-right',
escape: 'esc',
};
if (normalizedKey in toMobileKey) {
event.preventDefault();
event.stopPropagation();
handleMobileKeyPress(toMobileKey[normalizedKey]);
return;
}
if (activeModifier === 'ctrl' && upperKey && upperKey.length === 1) {
if (upperKey >= 'A' && upperKey <= 'Z') {
const controlCode = String.fromCharCode(upperKey.charCodeAt(0) & 0b11111);
event.preventDefault();
event.stopPropagation();
handleViewportInput(controlCode);
setActiveModifier(null);
focusTerminalController();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [
activeModifier,
handleMobileKeyPress,
handleViewportInput,
focusTerminalController,
showQuickKeys,
setActiveModifier,
terminalSessionId,
]);
React.useEffect(() => {
if (!showQuickKeys || !activeModifier || !terminalSessionId) return;
window.addEventListener('keydown', handleQuickKeyDown);
return () => window.removeEventListener('keydown', handleQuickKeyDown);
}, [activeModifier, handleQuickKeyDown, showQuickKeys, terminalSessionId]);
const resolvedFontStack = React.useMemo(() => {
const defaultStack = CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
@@ -929,6 +831,23 @@ export const TerminalView: React.FC = () => {
fitOnce();
}, [focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput, terminalViewportKey, terminalSessionId]);
React.useEffect(() => {
if (!isTerminalVisible || !useTouchTerminalInput) return;
let fitFrame: number | null = null;
const handleKeyboardSettled = () => {
if (fitFrame !== null) window.cancelAnimationFrame(fitFrame);
fitFrame = window.requestAnimationFrame(() => {
fitFrame = null;
terminalControllerRef.current?.fit();
});
};
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
return () => {
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
if (fitFrame !== null) window.cancelAnimationFrame(fitFrame);
};
}, [isTerminalVisible, terminalViewportKey, useTouchTerminalInput]);
React.useEffect(() => {
if (useTouchTerminalInput || !isTerminalVisible || !isBottomTerminalOpen) {
return;
@@ -947,7 +866,7 @@ export const TerminalView: React.FC = () => {
const rafId = window.requestAnimationFrame(() => {
fitOnce();
});
const timeoutIds = [0, 80, 180, 320].map((delay) => window.setTimeout(fitOnce, delay));
const timeoutIds = [320].map((delay) => window.setTimeout(fitOnce, delay));
return () => {
window.cancelAnimationFrame(rafId);
timeoutIds.forEach((id) => window.clearTimeout(id));
@@ -982,13 +901,18 @@ export const TerminalView: React.FC = () => {
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
const shouldRenderViewport = hasOpenedTerminalViewport;
const showBottomDockControls = !isTouchTerminal && isBottomTerminalOpen && !isTerminalActive;
const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs';
const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0';
const preserveTerminalFocus = (event: React.PointerEvent<HTMLButtonElement>) => {
if (isTouchTerminal) event.preventDefault();
};
const quickKeysControls = (
<>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 px-2 text-xs"
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
@@ -996,9 +920,10 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('tab')}
disabled={quickKeysDisabled}
>
@@ -1007,10 +932,11 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="chip"
aria-pressed={activeModifier === 'ctrl'}
className="h-6 w-9 p-0"
className={isTouchTerminal ? 'px-3' : 'px-2'}
onPointerDown={preserveTerminalFocus}
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
@@ -1019,21 +945,23 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="chip"
aria-pressed={activeModifier === 'cmd'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('cmd')}
aria-pressed={activeModifier === 'alt'}
className={isTouchTerminal ? 'px-3' : 'px-2'}
onPointerDown={preserveTerminalFocus}
onClick={() => handleModifierToggle('alt')}
disabled={quickKeysDisabled}
>
<Icon name="command"/>
<span className="sr-only">{t('terminalView.quickKeys.commandModifierAria')}</span>
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span>
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('arrow-up')}
disabled={quickKeysDisabled}
>
@@ -1042,9 +970,10 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('arrow-left')}
disabled={quickKeysDisabled}
>
@@ -1053,9 +982,10 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('arrow-down')}
disabled={quickKeysDisabled}
>
@@ -1064,9 +994,10 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('arrow-right')}
disabled={quickKeysDisabled}
>
@@ -1075,9 +1006,10 @@ export const TerminalView: React.FC = () => {
</Button>
<Button
type="button"
size="sm"
size={quickKeySize}
variant="outline"
className="h-6 w-9 p-0"
className={quickKeyIconClass}
onPointerDown={preserveTerminalFocus}
onClick={() => handleMobileKeyPress('enter')}
disabled={quickKeysDisabled}
>
@@ -1116,6 +1048,20 @@ export const TerminalView: React.FC = () => {
</Button>
<div className="flex shrink-0 items-center gap-1 overflow-visible">
<Button type="button" size="xs" variant="ghost" className="h-7 w-7 p-0" onClick={() => void handleRestart()} disabled={isRestarting} title={t('terminalView.actions.restart')} aria-label={t('terminalView.actions.restart')}>
<Icon name="restart" className="h-4 w-4" />
</Button>
<Button
type="button"
size="xs"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleAttachSelection}
title={t('terminalView.actions.attachSelection')}
aria-label={t('terminalView.actions.attachSelection')}
>
<Icon name="attachment-2" className="h-4 w-4" />
</Button>
{previewUrl ? (
<Button
type="button"
@@ -1162,13 +1108,13 @@ export const TerminalView: React.FC = () => {
</div>
) : null}
{!isMobile && showQuickKeys && enableTabs && directoryTerminalState ? (
{!isTouchTerminal && showQuickKeys && enableTabs && directoryTerminalState ? (
<div className="mt-2 flex flex-wrap items-center gap-1 pl-1 pr-1">
{quickKeysControls}
</div>
) : null}
{showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
{!isTouchTerminal && showQuickKeys && (!enableTabs || !directoryTerminalState) ? (
<div className="mt-2 flex flex-wrap items-center gap-1">
{quickKeysControls}
</div>
@@ -1202,7 +1148,7 @@ export const TerminalView: React.FC = () => {
{!isReconnectPending && connectionError && (
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
<span>{connectionError}</span>
{isFatalError && isMobile && (
{isFatalError && isTouchTerminal && (
<Button
size="sm"
variant="secondary"
@@ -1218,6 +1164,13 @@ export const TerminalView: React.FC = () => {
</div>
)}
</div>
{isTouchTerminal && showQuickKeys ? (
<div className="shrink-0 overflow-x-auto border-t border-border/40 bg-[var(--surface-background)] px-2 pt-1.5 pb-[max(0.375rem,calc(var(--oc-app-bottom-safe,0px)-var(--oc-keyboard-inset,0px)))] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-w-max items-center gap-1.5">
{quickKeysControls}
</div>
</div>
) : null}
</div>
);
};
+2 -15
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
@@ -62,17 +63,6 @@ export const useKeyboardShortcuts = () => {
React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
const isTerminalEventTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) {
return false;
}
return Boolean(
target.closest('.terminal-viewport-container') ||
target.getAttribute('data-terminal-hidden-input') === 'true'
);
};
const dropdownTargetSelector = [
'[data-slot="dropdown-menu-content"]',
'[data-slot="select-content"]',
@@ -528,10 +518,7 @@ export const useKeyboardShortcuts = () => {
const target = e.target as Element | null;
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]'));
const isInsideTerminal = Boolean(
target?.closest('.terminal-viewport-container') ||
target?.getAttribute('data-terminal-hidden-input') === 'true'
);
const isInsideTerminal = isTerminalEventTarget(target);
const hasDropdownInteraction = isDropdownEventTarget(target) || hasOpenDropdown();
const {
+1
View File
@@ -1459,6 +1459,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
/* Hide browser/system caret for Ghostty's own input surfaces.
Terminal cursor is rendered by the canvas renderer. */
.terminal-viewport-container[contenteditable="true"][aria-label="Terminal input"],
.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] {
caret-color: transparent !important;
outline: none !important;
+31 -30
View File
@@ -18,35 +18,27 @@ interface Subscription {
close: () => void;
}
interface RetryPolicy {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
}
interface TerminalTransportCapability {
preferred?: 'ws' | 'http' | 'sse';
transports?: Array<'ws' | 'http' | 'sse'>;
ws?: {
path: string;
v?: number;
enc?: string;
};
}
export interface TerminalSession {
sessionId: string;
cols: number;
rows: number;
capabilities?: {
input?: TerminalTransportCapability;
stream?: TerminalTransportCapability;
};
status: 'running' | 'exited' | 'error';
}
export type TerminalShell = 'auto' | 'bash' | 'zsh' | 'sh' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'dash' | 'ksh' | 'nu';
export interface TerminalShellOption {
id: TerminalShell;
name: string;
supportsLogin: boolean;
}
export interface TerminalStreamEvent {
type: 'connected' | 'data' | 'exit' | 'reconnecting';
type: 'snapshot' | 'data' | 'exit' | 'reconnecting';
sequence?: number;
data?: string;
replayData?: string;
status?: 'running' | 'exited' | 'error';
exitCode?: number;
signal?: number | null;
attempt?: number;
@@ -56,15 +48,20 @@ export interface TerminalStreamEvent {
ptyBackend?: string;
}
export interface CreateTerminalOptions {
cwd: string;
cols?: number;
rows?: number;
export interface TerminalError extends Error {
code?: string;
}
export interface TerminalStreamOptions {
retry?: Partial<RetryPolicy>;
connectionTimeoutMs?: number;
export interface CreateTerminalOptions {
cwd: string;
sessionId?: string;
cols?: number;
rows?: number;
themeMode?: 'light' | 'dark';
terminalBackground?: string;
terminalForeground?: string;
shell?: TerminalShell;
loginShell?: boolean;
}
export interface ResizeTerminalPayload {
@@ -75,7 +72,7 @@ export interface ResizeTerminalPayload {
export interface TerminalHandlers {
onEvent: (event: TerminalStreamEvent) => void;
onError?: (error: Error, fatal?: boolean) => void;
onError?: (error: TerminalError, fatal?: boolean) => void;
}
export interface ForceKillOptions {
@@ -84,10 +81,12 @@ export interface ForceKillOptions {
}
export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>;
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions): Subscription;
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
sendInput(sessionId: string, input: string): Promise<void>;
resize(payload: ResizeTerminalPayload): Promise<void>;
updateAppearance?(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void>;
close(sessionId: string): Promise<void>;
restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession>;
forceKill?(options: ForceKillOptions): Promise<void>;
@@ -657,6 +656,8 @@ export interface SettingsPayload {
showSplitAssistantMessageActions?: boolean;
fontSize?: number;
terminalFontSize?: number;
terminalShell?: TerminalShell;
terminalLoginShells?: TerminalShell[];
editorFontSize?: number;
uiFont?: string;
monoFont?: string;
+14 -21
View File
@@ -3,6 +3,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings } from '@/lib/desktop';
import type { MonoFontOption, UiFontOption } from '@/lib/fontOptions';
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import type { TerminalShell } from '@/lib/api/types';
type AppearanceSlice = {
showReasoningTraces: boolean;
@@ -34,6 +35,8 @@ type AppearanceSlice = {
sessionRetentionAction: 'archive' | 'delete';
fontSize: number;
terminalFontSize: number;
terminalShell: TerminalShell;
terminalLoginShells: TerminalShell[];
editorFontSize: number;
uiFont: UiFontOption;
monoFont: MonoFontOption;
@@ -79,6 +82,8 @@ export const startAppearanceAutoSave = (): void => {
sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
fontSize: useUIStore.getState().fontSize,
terminalFontSize: useUIStore.getState().terminalFontSize,
terminalShell: useUIStore.getState().terminalShell,
terminalLoginShells: useUIStore.getState().terminalLoginShells,
editorFontSize: useUIStore.getState().editorFontSize,
uiFont: useUIStore.getState().uiFont,
monoFont: useUIStore.getState().monoFont,
@@ -90,26 +95,6 @@ export const startAppearanceAutoSave = (): void => {
gitChangesViewMode: useUIStore.getState().gitChangesViewMode,
};
let pending: Partial<DesktopSettings> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
const payload = pending;
pending = null;
timer = null;
if (payload && Object.keys(payload).length > 0) {
void updateDesktopSettings(payload);
}
};
const schedule = (changes: Partial<DesktopSettings>) => {
pending = { ...(pending ?? {}), ...changes };
if (timer) {
return;
}
timer = setTimeout(flush, 150);
};
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
@@ -136,6 +121,8 @@ export const startAppearanceAutoSave = (): void => {
sessionRetentionAction: state.sessionRetentionAction,
fontSize: state.fontSize,
terminalFontSize: state.terminalFontSize,
terminalShell: state.terminalShell,
terminalLoginShells: state.terminalLoginShells,
editorFontSize: state.editorFontSize,
uiFont: state.uiFont,
monoFont: state.monoFont,
@@ -221,6 +208,12 @@ export const startAppearanceAutoSave = (): void => {
if (current.terminalFontSize !== previous.terminalFontSize) {
diff.terminalFontSize = current.terminalFontSize;
}
if (current.terminalShell !== previous.terminalShell) {
diff.terminalShell = current.terminalShell;
}
if (current.terminalLoginShells !== previous.terminalLoginShells) {
diff.terminalLoginShells = current.terminalLoginShells;
}
if (current.editorFontSize !== previous.editorFontSize) {
diff.editorFontSize = current.editorFontSize;
}
@@ -252,7 +245,7 @@ export const startAppearanceAutoSave = (): void => {
previous = current;
if (Object.keys(diff).length > 0) {
schedule(diff);
void updateDesktopSettings(diff);
}
});
+3 -1
View File
@@ -1,4 +1,4 @@
import type { ProjectEntry } from '@/lib/api/types';
import type { ProjectEntry, TerminalShell } from '@/lib/api/types';
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
import type { DraftStarterRef } from '@/lib/draftStarters';
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
@@ -163,6 +163,8 @@ export type DesktopSettings = {
showSplitAssistantMessageActions?: boolean;
fontSize?: number;
terminalFontSize?: number;
terminalShell?: TerminalShell;
terminalLoginShells?: TerminalShell[];
editorFontSize?: number;
uiFont?: string;
monoFont?: string;
+1
View File
@@ -43,6 +43,7 @@ export async function detectDevServerCommand(
command: devAction.command,
label: devAction.name || 'Start Preview',
actionId: devAction.id,
previewUrlHint: devAction.openUrl,
};
}
@@ -1761,6 +1761,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.fontSizePercentageAria': 'Font size percentage',
'settings.openchamber.visual.actions.resetFontSizeAria': 'Reset font size',
'settings.openchamber.visual.field.terminalFontSize': 'Terminal Font Size',
'settings.openchamber.visual.field.terminalShell': 'Terminal Shell',
'settings.openchamber.visual.field.terminalShellAria': 'Select terminal shell',
'settings.openchamber.visual.field.terminalShellHint': 'Restart the terminal to apply this change to the current session.',
'settings.openchamber.visual.field.terminalLoginShell': 'Start as login shell',
'settings.openchamber.visual.option.terminalShell.auto': 'Auto',
'settings.openchamber.visual.field.editorFontSize': 'Editor Font Size',
'settings.openchamber.visual.field.codeFont': 'Code Font',
'settings.openchamber.visual.field.selectCodeFontAria': 'Select code font',
+8
View File
@@ -2,6 +2,11 @@ import { settingsDict } from './en.settings';
export const dict = {
...settingsDict,
'terminalView.actions.attachSelection': 'Attach selected output',
'terminalView.actions.restart': 'Restart terminal',
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, lines {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Remove terminal context',
'common.loading': 'Loading...',
'common.unavailable': 'Unavailable',
'common.language.english': 'English',
@@ -86,6 +91,7 @@ export const dict = {
'mobile.menu.titleAria': 'Workspace tools',
'mobile.menu.files': 'Files',
'mobile.menu.changes': 'Changes',
'mobile.menu.terminal': 'Terminal',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instances',
'mobile.menu.update': 'Update',
@@ -1467,6 +1473,8 @@ export const dict = {
'terminalView.quickKeys.tabAria': 'Tab',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control modifier',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Alt modifier',
'terminalView.quickKeys.commandModifierAria': 'Command modifier',
'terminalView.quickKeys.arrowUpAria': 'Arrow up',
'terminalView.quickKeys.arrowLeftAria': 'Arrow left',
@@ -1728,6 +1728,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.fontSizePercentageAria": "Porcentaje de tamaño de fuente",
"settings.openchamber.visual.actions.resetFontSizeAria": "Restablecer tamaño de fuente",
"settings.openchamber.visual.field.terminalFontSize": "Tamaño de fuente del terminal",
"settings.openchamber.visual.field.terminalShell": "Shell del terminal",
"settings.openchamber.visual.field.terminalShellAria": "Seleccionar shell del terminal",
"settings.openchamber.visual.field.terminalShellHint": "Reinicia el terminal para aplicar este cambio a la sesión actual.",
"settings.openchamber.visual.field.terminalLoginShell": "Iniciar como shell de inicio de sesión",
"settings.openchamber.visual.option.terminalShell.auto": "Automático",
"settings.openchamber.visual.field.editorFontSize": "Tamaño de fuente del editor",
"settings.openchamber.visual.field.codeFont": "Fuente de código",
"settings.openchamber.visual.field.selectCodeFontAria": "Seleccionar fuente de código",
+8
View File
@@ -3,6 +3,11 @@ import { settingsDict } from './es.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, líneas {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Eliminar contexto del terminal',
"common.loading": "Cargando...",
"common.unavailable": "No disponible",
"common.language.english": "Inglés",
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.titleAria": "Herramientas del espacio de trabajo",
"mobile.menu.files": "Archivos",
"mobile.menu.changes": "Cambios",
"mobile.menu.terminal": "Terminal",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Instancias",
"mobile.menu.update": "Actualizar",
@@ -1445,6 +1451,8 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.tabAria": "Tab",
"terminalView.quickKeys.controlLabel": "Ctrl",
"terminalView.quickKeys.controlModifierAria": "Modificador de control",
"terminalView.quickKeys.altLabel": "Alt",
"terminalView.quickKeys.altModifierAria": "Modificador Alt",
"terminalView.quickKeys.commandModifierAria": "Modificador de comando",
"terminalView.quickKeys.arrowUpAria": "Flecha arriba",
"terminalView.quickKeys.arrowLeftAria": "Flecha izquierda",
@@ -1640,6 +1640,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.fontSizePercentageAria': 'Pourcentage de taille de police',
'settings.openchamber.visual.actions.resetFontSizeAria': 'Réinitialiser la taille de la police',
'settings.openchamber.visual.field.terminalFontSize': 'Taille de la police du terminal',
'settings.openchamber.visual.field.terminalShell': 'Shell du terminal',
'settings.openchamber.visual.field.terminalShellAria': 'Sélectionner le shell du terminal',
'settings.openchamber.visual.field.terminalShellHint': 'Redémarrez le terminal pour appliquer cette modification à la session actuelle.',
'settings.openchamber.visual.field.terminalLoginShell': 'Démarrer comme shell de connexion',
'settings.openchamber.visual.option.terminalShell.auto': 'Automatique',
'settings.openchamber.visual.field.editorFontSize': 'Taille de la police de l\'éditeur',
'settings.openchamber.visual.field.codeFont': 'Police de code',
'settings.openchamber.visual.field.selectCodeFontAria': 'Sélectionnez la police du code',
+8
View File
@@ -2,6 +2,11 @@ import { settingsDict } from './fr.settings';
export const dict = {
...settingsDict,
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
'terminalView.actions.restart': 'Redémarrer le terminal',
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, lignes {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Supprimer le contexte du terminal',
'common.loading': 'Chargement...',
'common.unavailable': 'Indisponible',
'common.language.english': 'Anglais',
@@ -1287,6 +1292,8 @@ export const dict = {
'terminalView.quickKeys.tabAria': 'Languette',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Modificateur de contrôle',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Modificateur Alt',
'terminalView.quickKeys.commandModifierAria': 'Modificateur de commande',
'terminalView.quickKeys.arrowUpAria': 'Flèche vers le haut',
'terminalView.quickKeys.arrowLeftAria': 'Flèche vers la gauche',
@@ -2598,6 +2605,7 @@ export const dict = {
'mobile.menu.titleAria': 'Outils de l’espace de travail',
'mobile.menu.files': 'Fichiers',
'mobile.menu.changes': 'Modifications',
'mobile.menu.terminal': 'Terminal',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instances',
'mobile.menu.update': 'Mettre à jour',
@@ -1761,6 +1761,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.fontSizePercentageAria': 'フォントサイズの割合',
'settings.openchamber.visual.actions.resetFontSizeAria': 'フォントサイズをリセット',
'settings.openchamber.visual.field.terminalFontSize': 'ターミナルフォントサイズ',
'settings.openchamber.visual.field.terminalShell': 'ターミナルシェル',
'settings.openchamber.visual.field.terminalShellAria': 'ターミナルシェルを選択',
'settings.openchamber.visual.field.terminalShellHint': 'この変更を現在のセッションに適用するには、ターミナルを再起動してください。',
'settings.openchamber.visual.field.terminalLoginShell': 'ログインシェルとして起動',
'settings.openchamber.visual.option.terminalShell.auto': '自動',
'settings.openchamber.visual.field.editorFontSize': 'エディターフォントサイズ',
'settings.openchamber.visual.field.codeFont': 'コードフォント',
'settings.openchamber.visual.field.selectCodeFontAria': 'コードフォントを選択',
+8
View File
@@ -3,6 +3,11 @@ import { settingsDict } from './ja.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': '選択した出力を添付',
'terminalView.actions.restart': 'ターミナルを再起動',
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
'chat.chatInput.terminalContext': '{terminal}、{start}〜{end}行',
'chat.chatInput.terminalContextRemove': 'ターミナルコンテキストを削除',
'common.loading': '読み込み中...',
'common.unavailable': '利用できません',
'common.language.english': '英語',
@@ -88,6 +93,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.titleAria': 'ワークスペースツール',
'mobile.menu.files': 'ファイル',
'mobile.menu.changes': '変更',
'mobile.menu.terminal': 'ターミナル',
'mobile.menu.mcp': 'MCP',
'mobile.menu.update': '更新',
'mobile.menu.settings': '設定',
@@ -1463,6 +1469,8 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.tabAria': 'Tab',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control修飾キー',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Alt修飾キー',
'terminalView.quickKeys.commandModifierAria': 'Command修飾キー',
'terminalView.quickKeys.arrowUpAria': '上矢印',
'terminalView.quickKeys.arrowLeftAria': '左矢印',
@@ -1728,6 +1728,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.fontSizePercentageAria': '폰트 크기 비율',
'settings.openchamber.visual.actions.resetFontSizeAria': '폰트 크기 초기화',
'settings.openchamber.visual.field.terminalFontSize': '터미널 폰트 크기',
'settings.openchamber.visual.field.terminalShell': '터미널 셸',
'settings.openchamber.visual.field.terminalShellAria': '터미널 셸 선택',
'settings.openchamber.visual.field.terminalShellHint': '이 변경 사항을 현재 세션에 적용하려면 터미널을 다시 시작하세요.',
'settings.openchamber.visual.field.terminalLoginShell': '로그인 셸로 시작',
'settings.openchamber.visual.option.terminalShell.auto': '자동',
'settings.openchamber.visual.field.editorFontSize': '에디터 폰트 크기',
'settings.openchamber.visual.field.codeFont': '코드 폰트',
'settings.openchamber.visual.field.selectCodeFontAria': '코드 폰트 선택',
+8
View File
@@ -3,6 +3,11 @@ import { settingsDict } from './ko.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': '선택한 출력 첨부',
'terminalView.actions.restart': '터미널 다시 시작',
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
'chat.chatInput.terminalContext': '{terminal}, {start}-{end}행',
'chat.chatInput.terminalContextRemove': '터미널 컨텍스트 제거',
'common.loading': '로딩 중...',
'common.unavailable': '사용할 수 없음',
'common.language.english': '영어',
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.titleAria': '작업 공간 도구',
'mobile.menu.files': '파일',
'mobile.menu.changes': '변경사항',
'mobile.menu.terminal': '터미널',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '인스턴스',
'mobile.menu.update': '업데이트',
@@ -1469,6 +1475,8 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.tabAria': 'Tab',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control 수정키',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Alt 수정키',
'terminalView.quickKeys.commandModifierAria': 'Command 수정키',
'terminalView.quickKeys.arrowUpAria': '위쪽 화살표',
'terminalView.quickKeys.arrowLeftAria': '왼쪽 화살표',
@@ -1040,6 +1040,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
'settings.openchamber.visual.field.terminalFontSize': 'Rozmiar czcionki terminala',
'settings.openchamber.visual.field.terminalShell': 'Powłoka terminala',
'settings.openchamber.visual.field.terminalShellAria': 'Wybierz powłokę terminala',
'settings.openchamber.visual.field.terminalShellHint': 'Uruchom terminal ponownie, aby zastosować tę zmianę w bieżącej sesji.',
'settings.openchamber.visual.field.terminalLoginShell': 'Uruchamiaj jako powłokę logowania',
'settings.openchamber.visual.option.terminalShell.auto': 'Automatycznie',
'settings.openchamber.visual.field.editorFontSize': 'Rozmiar czcionki edytora',
'settings.openchamber.visual.field.terminalQuickKeys': 'Szybkie klawisze terminala',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Szybkie klawisze terminala',
+8
View File
@@ -3,6 +3,11 @@ import { settingsDict } from './pl.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
'terminalView.actions.restart': 'Uruchom terminal ponownie',
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, wiersze {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Usuń kontekst terminala',
'common.loading': 'Ładowanie...',
'common.unavailable': 'Niedostępne',
@@ -88,6 +93,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.titleAria': 'Narzędzia obszaru roboczego',
'mobile.menu.files': 'Pliki',
'mobile.menu.changes': 'Zmiany',
'mobile.menu.terminal': 'Terminal',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instancje',
'mobile.menu.update': 'Aktualizuj',
@@ -2624,6 +2630,8 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.commandModifierAria': 'Command modifier',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control modifier',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Modyfikator Alt',
'terminalView.quickKeys.enterAria': 'Enter',
'terminalView.quickKeys.escape': 'Esc',
'terminalView.quickKeys.tabAria': 'Tab',
@@ -1728,6 +1728,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.fontSizePercentageAria": "Porcentagem do tamanho base",
"settings.openchamber.visual.actions.resetFontSizeAria": "Redefinir tamanho base",
"settings.openchamber.visual.field.terminalFontSize": "Tamanho base do terminal",
"settings.openchamber.visual.field.terminalShell": "Shell do terminal",
"settings.openchamber.visual.field.terminalShellAria": "Selecionar shell do terminal",
"settings.openchamber.visual.field.terminalShellHint": "Reinicie o terminal para aplicar esta alteração à sessão atual.",
"settings.openchamber.visual.field.terminalLoginShell": "Iniciar como shell de login",
"settings.openchamber.visual.option.terminalShell.auto": "Automático",
"settings.openchamber.visual.field.editorFontSize": "Tamanho base do editor",
"settings.openchamber.visual.field.codeFont": "Fonte do código",
"settings.openchamber.visual.field.selectCodeFontAria": "Selecionar fonte do código",
+9 -1
View File
@@ -2,7 +2,12 @@ import type { I18nKey } from './en';
import { settingsDict } from './pt-BR.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...settingsDict,
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, linhas {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Remover contexto do terminal',
"common.loading": "Carregando...",
"common.unavailable": "Indisponível",
"common.language.english": "Inglês",
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.titleAria": "Ferramentas do espaço de trabalho",
"mobile.menu.files": "Arquivos",
"mobile.menu.changes": "Alterações",
"mobile.menu.terminal": "Terminal",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Instâncias",
"mobile.menu.update": "Atualizar",
@@ -1445,6 +1451,8 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.tabAria": "Tab",
"terminalView.quickKeys.controlLabel": "Ctrl",
"terminalView.quickKeys.controlModifierAria": "Modificador Control",
"terminalView.quickKeys.altLabel": "Alt",
"terminalView.quickKeys.altModifierAria": "Modificador Alt",
"terminalView.quickKeys.commandModifierAria": "Modificador de comando",
"terminalView.quickKeys.arrowUpAria": "Flecha acima",
"terminalView.quickKeys.arrowLeftAria": "Flecha esquerda",
@@ -1728,6 +1728,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.fontSizePercentageAria": "Розмір шрифту у відсотках",
"settings.openchamber.visual.actions.resetFontSizeAria": "Скинути розмір шрифту",
"settings.openchamber.visual.field.terminalFontSize": "Розмір шрифту терміналу",
"settings.openchamber.visual.field.terminalShell": "Оболонка терміналу",
"settings.openchamber.visual.field.terminalShellAria": "Вибрати оболонку терміналу",
"settings.openchamber.visual.field.terminalShellHint": "Перезапустіть термінал, щоб застосувати цю зміну до поточної сесії.",
"settings.openchamber.visual.field.terminalLoginShell": "Запускати як оболонку входу",
"settings.openchamber.visual.option.terminalShell.auto": "Автоматично",
"settings.openchamber.visual.field.editorFontSize": "Розмір шрифту редактора",
"settings.openchamber.visual.field.codeFont": "Шрифт коду",
"settings.openchamber.visual.field.selectCodeFontAria": "Вибрати шрифт коду",
+8
View File
@@ -3,6 +3,11 @@ import { settingsDict } from './uk.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
'terminalView.actions.restart': 'Перезапустити термінал',
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, рядки {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Видалити контекст термінала',
"common.loading": "Завантаження...",
"common.unavailable": "Недоступно",
"common.language.english": "англійська",
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.titleAria": "Інструменти робочого простору",
"mobile.menu.files": "Файли",
"mobile.menu.changes": "Зміни",
"mobile.menu.terminal": "Термінал",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Інстанси",
"mobile.menu.update": "Оновити",
@@ -1445,6 +1451,8 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.tabAria": "Tab",
"terminalView.quickKeys.controlLabel": "Ctrl",
"terminalView.quickKeys.controlModifierAria": "Модифікатор Ctrl",
"terminalView.quickKeys.altLabel": "Alt",
"terminalView.quickKeys.altModifierAria": "Модифікатор Alt",
"terminalView.quickKeys.commandModifierAria": "Модифікатор Command",
"terminalView.quickKeys.arrowUpAria": "Стрілка вгору",
"terminalView.quickKeys.arrowLeftAria": "Стрілка вліво",
@@ -1728,6 +1728,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.fontSizePercentageAria': '字体大小百分比',
'settings.openchamber.visual.actions.resetFontSizeAria': '重置字体大小',
'settings.openchamber.visual.field.terminalFontSize': '终端字体大小',
'settings.openchamber.visual.field.terminalShell': '终端 Shell',
'settings.openchamber.visual.field.terminalShellAria': '选择终端 Shell',
'settings.openchamber.visual.field.terminalShellHint': '重启终端以将此更改应用到当前会话。',
'settings.openchamber.visual.field.terminalLoginShell': '作为登录 Shell 启动',
'settings.openchamber.visual.option.terminalShell.auto': '自动',
'settings.openchamber.visual.field.editorFontSize': '编辑器字体大小',
'settings.openchamber.visual.field.codeFont': '代码字体',
'settings.openchamber.visual.field.selectCodeFontAria': '选择代码字体',
@@ -3,6 +3,11 @@ import { settingsDict } from './zh-CN.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': '附加所选输出',
'terminalView.actions.restart': '重启终端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContextRemove': '移除终端上下文',
'common.loading': '加载中...',
'common.unavailable': '不可用',
'common.language.english': 'English',
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.titleAria': '工作区工具',
'mobile.menu.files': '文件',
'mobile.menu.changes': '更改',
'mobile.menu.terminal': '终端',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '实例',
'mobile.menu.update': '更新',
@@ -1433,6 +1439,8 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.tabAria': 'Tab 键',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control 修饰键',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Alt 修饰键',
'terminalView.quickKeys.commandModifierAria': 'Command 修饰键',
'terminalView.quickKeys.arrowUpAria': '上箭头',
'terminalView.quickKeys.arrowLeftAria': '左箭头',
@@ -1639,6 +1639,11 @@
'settings.openchamber.visual.field.fontSizePercentageAria': '字體大小百分比',
'settings.openchamber.visual.actions.resetFontSizeAria': '重設字體大小',
'settings.openchamber.visual.field.terminalFontSize': '終端機字體大小',
'settings.openchamber.visual.field.terminalShell': '終端機 Shell',
'settings.openchamber.visual.field.terminalShellAria': '選擇終端機 Shell',
'settings.openchamber.visual.field.terminalShellHint': '重新啟動終端機,將此變更套用至目前的工作階段。',
'settings.openchamber.visual.field.terminalLoginShell': '作為登入 Shell 啟動',
'settings.openchamber.visual.option.terminalShell.auto': '自動',
'settings.openchamber.visual.field.editorFontSize': '編輯器字體大小',
'settings.openchamber.visual.field.codeFont': '程式碼字體',
'settings.openchamber.visual.field.selectCodeFontAria': '選擇程式碼字體',
@@ -3,6 +3,11 @@ import { settingsDict } from './zh-TW.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'terminalView.actions.attachSelection': '附加所選輸出',
'terminalView.actions.restart': '重新啟動終端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContextRemove': '移除終端上下文',
'common.loading': '載入中...',
'common.unavailable': '無法使用',
'common.language.english': 'English',
@@ -87,6 +92,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.titleAria': '工作區工具',
'mobile.menu.files': '檔案',
'mobile.menu.changes': '變更',
'mobile.menu.terminal': '終端機',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '執行個體',
'mobile.menu.update': '更新',
@@ -1437,6 +1443,8 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.tabAria': 'Tab 鍵',
'terminalView.quickKeys.controlLabel': 'Ctrl',
'terminalView.quickKeys.controlModifierAria': 'Control 修飾鍵',
'terminalView.quickKeys.altLabel': 'Alt',
'terminalView.quickKeys.altModifierAria': 'Alt 修飾鍵',
'terminalView.quickKeys.commandModifierAria': 'Command 修飾鍵',
'terminalView.quickKeys.arrowUpAria': '上箭頭',
'terminalView.quickKeys.arrowLeftAria': '左箭頭',
+15 -8
View File
@@ -1,4 +1,5 @@
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { appendTerminalContexts } from './terminalContext';
/**
* Format a single inline comment draft into the standard message format
@@ -45,13 +46,19 @@ function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
*/
export function appendInlineComments(text: string, drafts: InlineCommentDraft[]): string {
if (drafts.length === 0) return text;
const formattedComments = formatInlineCommentDrafts(drafts);
if (!text.trim()) {
return formattedComments;
const terminalDrafts = drafts.filter((draft) => draft.source === 'terminal');
const otherDrafts = drafts.filter((draft) => draft.source !== 'terminal');
const withComments = otherDrafts.length > 0
? (text.trim() ? `${text}\n\n${formatInlineCommentDrafts(otherDrafts)}` : formatInlineCommentDrafts(otherDrafts))
: text;
if (terminalDrafts.length > 0) {
return appendTerminalContexts(withComments, terminalDrafts.map((draft) => ({
terminalId: draft.language,
terminalLabel: draft.fileLabel,
startLine: draft.startLine,
endLine: draft.endLine,
text: draft.code,
})));
}
return `${text}\n\n${formattedComments}`;
return withComments;
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test';
import { appendTerminalContexts, extractTerminalContexts, normalizeTerminalContext, terminalContextKey } from './terminalContext';
const context = {
terminalId: 'term-1', terminalLabel: 'Terminal 1', startLine: 12, endLine: 13, text: 'one\r\ntwo',
};
describe('terminal context serialization', () => {
test('normalizes immutable selection snapshots and line ranges', () => {
expect(normalizeTerminalContext(context)).toEqual({ ...context, text: 'one\ntwo' });
expect(normalizeTerminalContext({ ...context, text: '\n\n' })).toBeNull();
expect(normalizeTerminalContext({ ...context, startLine: -2, endLine: 0 })?.startLine).toBe(1);
});
test('serializes multiple contexts without exposing the block in visible text', () => {
const serialized = appendTerminalContexts('fix this', [context, { ...context, terminalId: 'term-2', terminalLabel: 'Build', startLine: 2, endLine: 2, text: 'failed' }]);
const parsed = extractTerminalContexts(serialized);
expect(parsed.visibleText).toBe('fix this');
expect(parsed.contexts).toEqual([
{ terminalLabel: 'Terminal 1', startLine: 12, endLine: 13, text: 'one\ntwo' },
{ terminalLabel: 'Build', startLine: 2, endLine: 2, text: 'failed' },
]);
});
test('ignores expired contexts and provides deterministic deduplication keys', () => {
expect(appendTerminalContexts('hello', [{ ...context, text: '' }])).toBe('hello');
expect(terminalContextKey(context)).toBe(terminalContextKey({ ...context }));
});
});
@@ -0,0 +1,54 @@
export type TerminalContext = {
terminalId: string;
terminalLabel: string;
startLine: number;
endLine: number;
text: string;
};
export type ParsedTerminalContext = Omit<TerminalContext, 'terminalId'>;
const BLOCK = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;
export const normalizeTerminalContext = (context: TerminalContext): TerminalContext | null => {
const terminalId = context.terminalId.trim();
const terminalLabel = context.terminalLabel.trim();
const text = context.text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
if (!terminalId || !terminalLabel || !text) return null;
const startLine = Math.max(1, Math.floor(context.startLine));
return { terminalId, terminalLabel, startLine, endLine: Math.max(startLine, Math.floor(context.endLine)), text };
};
export const terminalContextKey = (context: TerminalContext): string =>
`${context.terminalId}:${context.startLine}:${context.endLine}:${context.text}`;
export const appendTerminalContexts = (prompt: string, contexts: readonly TerminalContext[]): string => {
const normalized = contexts.map(normalizeTerminalContext).filter((value): value is TerminalContext => value !== null);
if (normalized.length === 0) return prompt;
const lines = normalized.flatMap((context, index) => [
`- ${context.terminalLabel} lines ${context.startLine}-${context.endLine}:`,
...context.text.split('\n').map((line, offset) => ` ${context.startLine + offset} | ${line}`),
...(index === normalized.length - 1 ? [] : ['']),
]);
const block = ['<terminal_context>', ...lines, '</terminal_context>'].join('\n');
return prompt.trim() ? `${prompt.trim()}\n\n${block}` : block;
};
export const extractTerminalContexts = (prompt: string): { visibleText: string; contexts: ParsedTerminalContext[] } => {
const match = BLOCK.exec(prompt);
if (!match) return { visibleText: prompt, contexts: [] };
const contexts: ParsedTerminalContext[] = [];
let current: ParsedTerminalContext | null = null;
for (const line of (match[1] ?? '').split('\n')) {
const header = /^- (.+) lines (\d+)-(\d+):$/.exec(line);
if (header) {
if (current) contexts.push(current);
current = { terminalLabel: header[1], startLine: Number(header[2]), endLine: Number(header[3]), text: '' };
continue;
}
const body = /^\s{2}\d+ \| ?(.*)$/.exec(line);
if (current && body) current.text += `${current.text ? '\n' : ''}${body[1]}`;
}
if (current) contexts.push(current);
return { visibleText: prompt.slice(0, match.index).trimEnd(), contexts };
};
+132 -1
View File
@@ -3,11 +3,15 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore';
import { applyPersistedHomeDirectoryToWindow, syncDesktopSettings, updateDesktopSettings } from './persistence';
import { applyPersistedHomeDirectoryToWindow, invalidateSettingsCache, syncDesktopSettings, updateDesktopSettings } from './persistence';
import { switchRuntimeEndpoint } from './runtime-switch';
type TestWindow = {
__OPENCHAMBER_HOME__?: string;
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
dispatchEvent: (event: Event) => boolean;
setTimeout: typeof setTimeout;
clearTimeout: typeof clearTimeout;
@@ -51,6 +55,12 @@ const getWindow = (): TestWindow => {
createdWindow = true;
}
const testWindow = window as unknown as Partial<TestWindow>;
if (!testWindow.addEventListener || !testWindow.removeEventListener) {
const eventTarget = new EventTarget();
testWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget);
testWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
testWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
}
testWindow.dispatchEvent ??= () => true;
testWindow.setTimeout ??= setTimeout;
testWindow.clearTimeout ??= clearTimeout;
@@ -59,6 +69,15 @@ const getWindow = (): TestWindow => {
};
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const registerSettingsApi = (
save: (changes: Partial<SettingsPayload>) => Promise<SettingsPayload>,
@@ -124,6 +143,7 @@ describe('updateDesktopSettings', () => {
beforeEach(() => {
getWindow();
registerRuntimeAPIs(null);
invalidateSettingsCache();
resetModelPrefsState();
});
@@ -196,6 +216,82 @@ describe('updateDesktopSettings', () => {
expect(secondResolved).toBe(true);
});
test('drains a pending save to the previous runtime and ignores its stale response', async () => {
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' });
const saveResult = deferred<SettingsPayload>();
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave((changes) => {
saveCalls.push(changes);
return saveResult.promise;
});
const update = updateDesktopSettings({ terminalShell: 'zsh' });
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-b.example', runtimeKey: 'settings-b' });
registerSettingsSave(async (changes) => changes as SettingsPayload);
useUIStore.getState().setTerminalShell('fish');
expect(saveCalls).toEqual([{ terminalShell: 'zsh' }]);
saveResult.resolve({ terminalShell: 'zsh' });
await update;
expect(useUIStore.getState().terminalShell).toBe('fish');
});
test('does not retry a failed old-runtime save against the new runtime', async () => {
const previousFetch = globalThis.fetch;
const fallbackRequests: string[] = [];
const saveResult = deferred<SettingsPayload>();
try {
globalThis.fetch = (async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === 'PUT' && url.includes('/api/config/settings')) fallbackRequests.push(url);
return new Response(null, { status: 404 });
}) as typeof fetch;
switchRuntimeEndpoint({ apiBaseUrl: 'https://failed-save-a.example', runtimeKey: 'failed-save-a' });
registerSettingsSave(() => saveResult.promise);
const update = updateDesktopSettings({ terminalShell: 'zsh' });
switchRuntimeEndpoint({ apiBaseUrl: 'https://failed-save-b.example', runtimeKey: 'failed-save-b' });
registerSettingsSave(async (changes) => changes as SettingsPayload);
saveResult.reject(new Error('runtime A disconnected'));
await update;
expect(fallbackRequests).toEqual([]);
} finally {
globalThis.fetch = previousFetch;
}
});
test('rejects stale loads by generation across an A to B to A switch', async () => {
const originalLoad = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-a.example', runtimeKey: 'load-a' });
registerSettingsApi(async () => ({}), () => originalLoad.promise);
const firstSync = syncDesktopSettings();
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-b.example', runtimeKey: 'load-b' });
registerSettingsApi(async () => ({}), async () => ({
settings: { terminalShell: 'fish', draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().terminalShell).toBe('fish');
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-a.example', runtimeKey: 'load-a' });
registerSettingsApi(async () => ({}), async () => ({
settings: { terminalShell: 'bash', draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().terminalShell).toBe('bash');
originalLoad.resolve({
settings: { terminalShell: 'zsh', draftStartersCraftGoalAdded: true },
source: 'web',
});
await firstSync;
expect(useUIStore.getState().terminalShell).toBe('bash');
});
test('applies model selector settings from server settings', async () => {
getWindow();
const settings = {
@@ -220,6 +316,22 @@ describe('updateDesktopSettings', () => {
expect(state.recentEfforts).toEqual(settings.recentEfforts);
});
test('applies the persisted terminal shell from server settings', async () => {
getWindow();
invalidateSettingsCache();
useUIStore.getState().setTerminalShell('auto');
useUIStore.getState().setTerminalLoginShells([]);
registerSettingsApi(async () => ({}), async () => ({
settings: { terminalShell: 'zsh', terminalLoginShells: ['zsh', 'fish'] },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().terminalShell).toBe('zsh');
expect(useUIStore.getState().terminalLoginShells).toEqual(['zsh', 'fish']);
});
test('autosaves all model selector settings fields', async () => {
getWindow();
const saveCalls: Array<Partial<SettingsPayload>> = [];
@@ -256,4 +368,23 @@ describe('updateDesktopSettings', () => {
stop();
}
});
test('autosaves terminal shell changes to shared settings', async () => {
getWindow();
useUIStore.getState().setTerminalShell('auto');
useUIStore.getState().setTerminalLoginShells([]);
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return changes as SettingsPayload;
});
startAppearanceAutoSave();
useUIStore.getState().setTerminalShell('zsh');
useUIStore.getState().setTerminalLoginShells(['zsh']);
await delay(500);
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true);
});
});
+165 -79
View File
@@ -10,6 +10,8 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sanitizeStarterRefs } from '@/lib/draftStarters';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') {
@@ -629,6 +631,18 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) {
store.setTerminalFontSize(settings.terminalFontSize);
}
if (isTerminalShell(settings.terminalShell) && settings.terminalShell !== store.terminalShell) {
store.setTerminalShell(settings.terminalShell);
}
if (
Array.isArray(settings.terminalLoginShells)
&& (
settings.terminalLoginShells.length !== store.terminalLoginShells.length
|| settings.terminalLoginShells.some((shell, index) => shell !== store.terminalLoginShells[index])
)
) {
store.setTerminalLoginShells(settings.terminalLoginShells);
}
if (typeof settings.editorFontSize === 'number' && Number.isFinite(settings.editorFontSize) && settings.editorFontSize !== store.editorFontSize) {
store.setEditorFontSize(settings.editorFontSize);
}
@@ -1168,6 +1182,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
result.terminalFontSize = candidate.terminalFontSize;
}
if (isTerminalShell(candidate.terminalShell)) {
result.terminalShell = candidate.terminalShell;
}
if (Array.isArray(candidate.terminalLoginShells)) {
result.terminalLoginShells = [...new Set(candidate.terminalLoginShells.filter(isTerminalShell))];
}
if (typeof candidate.editorFontSize === 'number' && Number.isFinite(candidate.editorFontSize)) {
result.editorFontSize = candidate.editorFontSize;
}
@@ -1311,52 +1331,105 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
return result;
};
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsCache: { value: DesktopSettings | null; at: number } | null = null;
let _settingsInflight: Promise<DesktopSettings | null> | null = null;
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
const fetchWebSettings = async (): Promise<DesktopSettings | null> => {
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsRuntimeGeneration = 0;
let _settingsCache: { value: DesktopSettings | null; at: number; context: SettingsRuntimeContext } | null = null;
let _settingsInflight: { promise: Promise<DesktopSettings | null>; context: SettingsRuntimeContext } | null = null;
let _pendingSettingsChanges: Partial<DesktopSettings> | null = null;
let _pendingSettingsContext: SettingsRuntimeContext | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
let _settingsLifecycleInitialized = false;
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
const SETTINGS_DEBOUNCE_MS = 200;
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
runtimeKey: getRuntimeKey(),
generation: _settingsRuntimeGeneration,
});
const isSameSettingsRuntimeContext = (left: SettingsRuntimeContext, right: SettingsRuntimeContext): boolean => (
left.runtimeKey === right.runtimeKey && left.generation === right.generation
);
const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boolean => (
context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey()
);
const ensureSettingsRuntimeLifecycle = (): void => {
if (_settingsLifecycleInitialized || typeof window === 'undefined') return;
_settingsLifecycleInitialized = true;
subscribeRuntimeEndpointWillChange((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
if (_settingsFlushTimer) clearTimeout(_settingsFlushTimer);
if (_pendingSettingsChanges) void _flushSettingsUpdate();
});
subscribeRuntimeEndpointChanged((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
_settingsRuntimeGeneration += 1;
_settingsCache = null;
_settingsInflight = null;
});
};
const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise<DesktopSettings | null> => {
ensureSettingsRuntimeLifecycle();
// Return cached if fresh
if (_settingsCache && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) {
if (_settingsCache && isSameSettingsRuntimeContext(_settingsCache.context, context) && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) {
return _settingsCache.value;
}
// Dedup concurrent calls
if (_settingsInflight) return _settingsInflight;
if (_settingsInflight && isSameSettingsRuntimeContext(_settingsInflight.context, context)) return _settingsInflight.promise;
_settingsInflight = (async (): Promise<DesktopSettings | null> => {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
const inflight = {
context,
promise: (async (): Promise<DesktopSettings | null> => {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(result.settings);
_settingsCache = { value: settings, at: Date.now(), context };
return settings;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
console.warn('Failed to load shared settings from runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return null;
try {
const result = await runtimeSettings.load();
const settings = sanitizeWebSettings(result.settings);
_settingsCache = { value: settings, at: Date.now() };
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!isSettingsRuntimeContextCurrent(context)) return null;
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(data);
_settingsCache = { value: settings, at: Date.now(), context };
return settings;
} catch (error) {
console.warn('Failed to load shared settings from runtime settings API:', error);
}
}
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
console.warn('Failed to load shared settings from server:', error);
return null;
}
const data = await response.json().catch(() => null);
const settings = sanitizeWebSettings(data);
_settingsCache = { value: settings, at: Date.now() };
return settings;
} catch (error) {
console.warn('Failed to load shared settings from server:', error);
return null;
}
})().finally(() => { _settingsInflight = null; });
})(),
};
_settingsInflight = inflight;
void inflight.promise.finally(() => {
if (_settingsInflight === inflight) _settingsInflight = null;
});
return _settingsInflight;
return inflight.promise;
};
/** Invalidate cached settings (call after a successful PUT) */
@@ -1368,6 +1441,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
const persistApi = getPersistApi();
@@ -1402,6 +1477,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
// a TypeError from writing to a contextBridge-protected global) doesn't
// prevent server settings from reaching the Zustand store.
const applySettings = async (settings: DesktopSettings) => {
if (!isSettingsRuntimeContextCurrent(context)) return;
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true;
try {
persistToLocalStorage(settings);
@@ -1409,6 +1485,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
applyDesktopUiPreferences(settings);
} catch (error) {
@@ -1419,14 +1496,15 @@ export const syncDesktopSettings = async (): Promise<void> => {
...(settings.draftStarters ? { draftStarters: settings.draftStarters } : {}),
draftStartersCraftGoalAdded: true,
});
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(settings);
};
try {
const webSettings = await fetchWebSettings();
if (webSettings) {
const webSettings = await fetchWebSettings(context);
if (webSettings && isSettingsRuntimeContextCurrent(context)) {
await applySettings(webSettings);
}
} catch (error) {
@@ -1435,75 +1513,83 @@ export const syncDesktopSettings = async (): Promise<void> => {
};
// Coalesce rapid updateDesktopSettings calls into a single PUT
let _pendingSettingsChanges: Partial<DesktopSettings> | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
const SETTINGS_DEBOUNCE_MS = 200;
const _flushSettingsUpdate = async (): Promise<void> => {
async function _flushSettingsUpdate(): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const waiters = _settingsFlushWaiters;
_pendingSettingsChanges = null;
_pendingSettingsContext = null;
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
if (!changes || Object.keys(changes).length === 0) {
waiters.forEach((resolve) => resolve());
return;
}
try {
if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) return;
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
_settingsCache = null;
}
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const updated = await runtimeSettings.save(changes);
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
return;
}
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
}
waiters.forEach((resolve) => resolve());
return;
} catch (error) {
console.warn('Failed to update settings via runtime settings API:', error);
if (isSettingsRuntimeContextCurrent(context)) console.warn('Failed to update shared settings via API:', error);
}
}
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
return;
}
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
}
} catch (error) {
console.warn('Failed to update shared settings via API:', error);
} finally {
waiters.forEach((resolve) => resolve());
}
};
}
export const updateDesktopSettings = async (changes: Partial<DesktopSettings>): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
if (_pendingSettingsContext && !isSameSettingsRuntimeContext(_pendingSettingsContext, context)) {
if (_settingsFlushTimer) clearTimeout(_settingsFlushTimer);
void _flushSettingsUpdate();
}
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context;
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import {
getPreviewTargetErrorCode,
getPreviewTargetRecoveryAction,
PREVIEW_TARGET_ERROR_HEADER,
} from './proxy-response';
describe('preview proxy response classification', () => {
test('recognizes proxy-owned target failures', () => {
for (const code of ['missing', 'expired', 'invalid-token'] as const) {
const headers = new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: code });
expect(getPreviewTargetErrorCode(headers)).toBe(code);
}
});
test('does not classify ordinary upstream responses as target failures', () => {
expect(getPreviewTargetErrorCode(new Headers())).toBeNull();
expect(getPreviewTargetErrorCode(new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: 'unknown' }))).toBeNull();
expect(getPreviewTargetRecoveryAction(new Headers(), false)).toBe('none');
});
test('bounds automatic target recovery to one registration retry', () => {
const headers = new Headers({ [PREVIEW_TARGET_ERROR_HEADER]: 'expired' });
expect(getPreviewTargetRecoveryAction(headers, false)).toBe('retry-registration');
expect(getPreviewTargetRecoveryAction(headers, true)).toBe('stop-retrying');
});
});
@@ -0,0 +1,18 @@
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
type PreviewTargetErrorCode = 'missing' | 'expired' | 'invalid-token';
export const getPreviewTargetErrorCode = (headers: Pick<Headers, 'get'>): PreviewTargetErrorCode | null => {
const value = headers.get(PREVIEW_TARGET_ERROR_HEADER);
return value === 'missing' || value === 'expired' || value === 'invalid-token'
? value
: null;
};
export const getPreviewTargetRecoveryAction = (
headers: Pick<Headers, 'get'>,
recoveryAttempted: boolean,
): 'none' | 'retry-registration' | 'stop-retrying' => {
if (!getPreviewTargetErrorCode(headers)) return 'none';
return recoveryAttempted ? 'stop-retrying' : 'retry-registration';
};
@@ -198,8 +198,9 @@ const TRANSPARENT_IMAGE_PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUh
// switches, but intentionally does NOT survive a full page reload: the server
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
// the proxy id, so a stale persisted entry would 404 after a server restart.
// Entries are evicted on registration error (refetched) or when the upstream
// returns 403 (cookie expired) / 404 (target unknown) at iframe load time.
// Entries are evicted on registration error or when the preview proxy marks a
// target as missing, expired, or unauthorized. Upstream 4xx responses are not
// cache failures.
export type CachedProxyTarget = { proxyBasePath: string; previewToken?: string; expiresAt: number };
export const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
const previewProxyTargetRequests = new Map<string, Promise<CachedProxyTarget | null>>();
@@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test';
import type { TerminalAPI, TerminalHandlers } from './api/types';
import { waitForTerminalExit } from './projectActionTerminal';
import { detectDevServerCommand } from './detectDevServer';
const fakeTerminal = () => {
let handlers: TerminalHandlers | null = null;
let closed = false;
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'term-1', cols: 80, rows: 24, status: 'running' }),
connect: (_id, nextHandlers) => { handlers = nextHandlers; return { close: () => { closed = true; } }; },
sendInput: async () => {}, resize: async () => {}, close: async () => {},
};
return { terminal, emit: (event: Parameters<TerminalHandlers['onEvent']>[0]) => handlers?.onEvent(event), isClosed: () => closed };
};
describe('project action terminal lifecycle', () => {
test('preserves a configured dev action preview URL', async () => {
const detected = await detectDevServerCommand('/repo', [{
id: 'dev',
name: 'Dev server',
command: 'bun run dev',
openUrl: 'http://localhost:4321',
}], null);
expect(detected?.previewUrlHint).toBe('http://localhost:4321');
});
test('resolves on live exit and closes its temporary subscription', async () => {
const fake = fakeTerminal();
const result = waitForTerminalExit(fake.terminal, 'term-1', 100);
fake.emit({ type: 'exit', sequence: 2, exitCode: 0 });
expect(await result).toBe(true);
expect(fake.isClosed()).toBe(true);
});
test('recognizes an already-exited reconnect snapshot', async () => {
const fake = fakeTerminal();
const result = waitForTerminalExit(fake.terminal, 'term-1', 100);
fake.emit({ type: 'snapshot', sequence: 2, status: 'exited', data: 'done' });
expect(await result).toBe(true);
});
test('returns false on timeout so the caller can force-kill', async () => {
const fake = fakeTerminal();
expect(await waitForTerminalExit(fake.terminal, 'term-1', 5)).toBe(false);
expect(fake.isClosed()).toBe(true);
});
});
@@ -0,0 +1,26 @@
import type { TerminalAPI } from './api/types';
export const waitForTerminalExit = (
terminal: TerminalAPI,
sessionId: string,
timeoutMs: number,
): Promise<boolean> => new Promise((resolve) => {
let settled = false;
let subscription: { close: () => void } | null = null;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (exited: boolean) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
subscription?.close();
resolve(exited);
};
subscription = terminal.connect(sessionId, {
onEvent: (event) => {
if (event.type === 'exit' || (event.type === 'snapshot' && event.status === 'exited')) finish(true);
},
onError: (_error, fatal) => { if (fatal) finish(true); },
});
if (settled) subscription.close();
else timeout = setTimeout(() => finish(false), timeoutMs);
});
+50 -1
View File
@@ -1,8 +1,57 @@
import { describe, expect, test } from 'bun:test';
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from './runtime-switch';
import {
getRuntimeApiBaseUrl,
getRuntimeKey,
subscribeRuntimeEndpointChanged,
subscribeRuntimeEndpointWillChange,
switchRuntimeEndpoint,
} from './runtime-switch';
import { clearRuntimeUrlAuthToken, setRuntimeExtraHeaders } from './runtime-auth';
describe('runtime endpoint switching', () => {
test('notifies listeners before and after mutating the active endpoint', () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousFetch = globalThis.fetch;
const events = new EventTarget();
const runtimeWindow = {
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
};
try {
globalThis.fetch = (async () => new Response(null, { status: 404 })) as typeof fetch;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: runtimeWindow,
});
switchRuntimeEndpoint({ apiBaseUrl: 'https://runtime-a.example', runtimeKey: 'runtime-a' });
const observed: Array<[string, string, string]> = [];
const unsubscribeWillChange = subscribeRuntimeEndpointWillChange((detail) => {
observed.push(['will-change', getRuntimeKey(), detail.previousRuntimeKey]);
});
const unsubscribeChanged = subscribeRuntimeEndpointChanged((detail) => {
observed.push(['changed', getRuntimeKey(), detail.runtimeKey]);
});
switchRuntimeEndpoint({ apiBaseUrl: 'https://runtime-b.example', runtimeKey: 'runtime-b' });
expect(observed).toEqual([
['will-change', 'runtime-a', 'runtime-a'],
['changed', 'runtime-b', 'runtime-b'],
]);
unsubscribeWillChange();
unsubscribeChanged();
} finally {
globalThis.fetch = previousFetch;
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
test('does not throw when Electron preload globals are read-only', () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousFetch = globalThis.fetch;
+18 -1
View File
@@ -17,6 +17,7 @@ export type RuntimeEndpointChangedDetail = {
};
const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed';
const RUNTIME_ENDPOINT_WILL_CHANGE_EVENT = 'openchamber:runtime-endpoint-will-change';
let activeApiBaseUrl = '';
let activeRuntimeKey = '';
@@ -43,7 +44,10 @@ const normalizeRuntimeUrlKey = (value: string): string => {
const url = new URL(value);
url.hash = '';
url.search = '';
// Normalise pathname so root `/` becomes empty and no path ends with `/`.
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
// url.toString() still appends `/` when pathname is `/`; strip it
// so every key uses the bare-origin form: `url:https://example.com`.
return `url:${url.toString().replace(/\/+$/, '')}`;
} catch {
return `url:${value.trim().replace(/\/+$/, '') || 'default'}`;
@@ -98,6 +102,10 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
const previousApiBaseUrl = getRuntimeApiBaseUrl();
const previousRuntimeKey = getRuntimeKey();
const runtimeKey = options.runtimeKey?.trim() || normalizeRuntimeUrlKey(apiBaseUrl);
const detail = { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey };
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, { detail }));
}
activeApiBaseUrl = apiBaseUrl;
activeRuntimeKey = runtimeKey;
if (typeof window !== 'undefined') {
@@ -124,11 +132,20 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_CHANGED_EVENT, {
detail: { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey },
detail,
}));
}
};
export const subscribeRuntimeEndpointWillChange = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
callback((event as CustomEvent<RuntimeEndpointChangedDetail>).detail);
};
window.addEventListener(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, listener);
return () => window.removeEventListener(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, listener);
};
export const subscribeRuntimeEndpointChanged = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
+8
View File
@@ -116,6 +116,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.field.terminalFontSize',
keywords: ['terminal', 'font', 'text size'],
},
{
id: 'appearance.terminal-shell',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.terminalShell',
descriptionKey: 'settings.openchamber.visual.field.terminalShellHint',
keywords: ['terminal', 'shell', 'bash', 'zsh', 'fish', 'pwsh', 'powershell'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.editor-font-size',
page: 'appearance',
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, test } from 'bun:test';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { TerminalTransport } from './terminalApi';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const frame = (message: Record<string, unknown>): Uint8Array => {
const body = encoder.encode(JSON.stringify(message));
const result = new Uint8Array(body.length + 1);
result[0] = 1;
result.set(body, 1);
return result;
};
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): Record<string, unknown> => {
const bytes = typeof value === 'string'
? encoder.encode(value)
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return JSON.parse(decoder.decode(bytes.subarray(1))) as Record<string, unknown>;
};
class FakeSocket implements RelayTunnelWebSocket {
readyState = 0;
binaryType: 'blob' | 'arraybuffer' = 'arraybuffer';
onopen: (() => void) | null = null;
onmessage: RelayTunnelWebSocket['onmessage'] = null;
onerror: (() => void) | null = null;
onclose: RelayTunnelWebSocket['onclose'] = null;
sent: Record<string, unknown>[] = [];
open(): void { this.readyState = 1; this.onopen?.(); }
emit(message: Record<string, unknown>): void {
const bytes = frame(message);
this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
}
send(data: string | ArrayBuffer | ArrayBufferView): void { this.sent.push(parseFrame(data)); }
close(): void { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }); }
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('terminal transport', () => {
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const firstEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => firstEvents.push(`${event.type}:${event.data ?? ''}`) });
await tick();
socket.open();
await tick();
expect(socket.sent.some((message) => message.t === 'attach' && message.s === 'term-1')).toBe(true);
expect(socket.sent.filter((message) => message.t === 'attach')).toHaveLength(1);
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running' });
await tick();
const secondEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => secondEvents.push(`${event.type}:${event.data ?? ''}`) });
expect(secondEvents).toEqual(['snapshot:prompt']);
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 2, d: ' next' });
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 2, d: ' duplicate' });
await tick();
expect(firstEvents).toEqual(['snapshot:prompt', 'data: next']);
expect(secondEvents).toEqual(['snapshot:prompt', 'data: next']);
const thirdEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => thirdEvents.push(`${event.type}:${event.data ?? ''}`) });
expect(thirdEvents).toEqual(['snapshot:prompt next']);
transport.dispose();
});
test('recovers when opening the first websocket fails', async () => {
if (typeof document !== 'undefined') Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
if (typeof navigator !== 'undefined') Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
const socket = new FakeSocket();
let attempts = 0;
const events: string[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => {
attempts += 1;
if (attempts === 1) throw new Error('offline');
queueMicrotask(() => socket.open());
return socket;
},
});
transport.subscribe('term-1', { onEvent: (event) => events.push(event.type) });
await new Promise((resolve) => setTimeout(resolve, 550));
expect(attempts).toBe(2);
expect(events).toContain('reconnecting');
expect(socket.sent.some((message) => message.t === 'attach')).toBe(true);
transport.dispose();
});
test('releases replay projections when the last subscriber detaches', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.open();
await tick();
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'large replay', status: 'running' });
await tick();
unsubscribe();
const events: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => events.push(event.type) });
expect(events).toEqual([]);
transport.dispose();
});
test('uses replay-safe output for projections and preserves terminal error codes', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
let errorCode: string | undefined;
transport.subscribe('term-1', { onEvent: () => {}, onError: (error) => { errorCode = error.code; } });
await tick();
socket.open();
await tick();
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 0, history: '', status: 'running' });
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 1, d: 'prompt\u001b[6n', r: 'prompt' });
await tick();
const replay: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') replay.push(event.data ?? ''); } });
expect(replay).toEqual(['prompt']);
socket.emit({ t: 'error', v: 3, s: 'term-1', code: 'SESSION_NOT_FOUND', message: 'missing', fatal: true });
await tick();
expect(errorCode).toBe('SESSION_NOT_FOUND');
transport.dispose();
});
test('does not reconnect after the last subscriber detaches', async () => {
let attempts = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => {
attempts += 1;
throw new Error('offline');
},
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
unsubscribe();
await new Promise((resolve) => setTimeout(resolve, 550));
expect(attempts).toBe(0);
transport.dispose();
});
test('single-flights auth and socket opening for an immediate first write', async () => {
const sockets: FakeSocket[] = [];
let authCalls = 0;
const transport = new TerminalTransport({
refreshAuth: async () => { authCalls += 1; await tick(); },
openSocket: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.open());
return socket;
},
});
transport.subscribe('term-1', { onEvent: () => {} });
await transport.write('term-1', 'bun run dev\r');
expect(authCalls).toBe(1);
expect(sockets).toHaveLength(1);
expect(sockets[0].sent.filter((message) => message.t === 'write')).toEqual([
{ t: 'write', v: 3, s: 'term-1', d: 'bun run dev\r' },
]);
transport.dispose();
});
});
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from 'bun:test';
import { getTerminalFocusOwner, isTerminalEventTarget } from './terminalFocus';
describe('terminal focus ownership', () => {
test('finds connected terminal ancestors and rejects detached renderer inputs', () => {
const originalElement = globalThis.Element;
class TestElement {
isConnected = true;
dataset = { terminalOwner: 'main' };
closest() { return this; }
}
Object.defineProperty(globalThis, 'Element', { configurable: true, value: TestElement });
try {
const input = new TestElement() as unknown as Element;
expect(getTerminalFocusOwner(input)).toBe('main');
expect(isTerminalEventTarget(input)).toBe(true);
(input as unknown as TestElement).isConnected = false;
expect(getTerminalFocusOwner(input)).toBeNull();
} finally {
Object.defineProperty(globalThis, 'Element', { configurable: true, value: originalElement });
}
});
});
+8
View File
@@ -0,0 +1,8 @@
export const getTerminalFocusOwner = (target: EventTarget | null): string | null => {
if (typeof Element === 'undefined' || !(target instanceof Element)) return null;
const owner = target.closest<HTMLElement>('[data-terminal-owner]');
if (!owner?.isConnected) return null;
return owner.dataset.terminalOwner || null;
};
export const isTerminalEventTarget = (target: EventTarget | null): boolean => getTerminalFocusOwner(target) !== null;
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey } from './terminalInput';
describe('terminal input translation', () => {
test('translates navigation, editing, and control keys', () => {
expect(terminalSequenceForKey('arrow-up', null)).toBe('\u001b[A');
expect(terminalSequenceForKey('arrow-left', 'ctrl')).toBe('\u001b[1;5D');
expect(terminalSequenceForKey('arrow-right', 'alt')).toBe('\u001b[1;3C');
expect(terminalSequenceForKey('enter', null)).toBe('\r');
expect(terminalControlCharacter('c')).toBe('\u0003');
expect(terminalControlCharacter('[')).toBeNull();
expect(applyTerminalModifier('c', 'ctrl')).toBe('\u0003');
expect(applyTerminalModifier('b', 'alt')).toBe('\u001bb');
expect(applyTerminalModifier('\u001b[1;3C', 'alt')).toBe('\u001b[1;3C');
});
});
+28
View File
@@ -0,0 +1,28 @@
export type TerminalModifier = 'ctrl' | 'alt';
export type TerminalQuickKey = 'esc' | 'tab' | 'enter' | 'arrow-up' | 'arrow-down' | 'arrow-left' | 'arrow-right';
const sequences: Record<TerminalQuickKey, string> = {
esc: '\u001b', tab: '\t', enter: '\r',
'arrow-up': '\u001b[A', 'arrow-down': '\u001b[B', 'arrow-left': '\u001b[D', 'arrow-right': '\u001b[C',
};
export const terminalSequenceForKey = (key: TerminalQuickKey, modifier: TerminalModifier | null): string => {
if (modifier && key.startsWith('arrow-')) {
const suffix = modifier === 'ctrl' ? '5' : '3';
const direction = { 'arrow-up': 'A', 'arrow-down': 'B', 'arrow-right': 'C', 'arrow-left': 'D' }[key as 'arrow-up' | 'arrow-down' | 'arrow-right' | 'arrow-left'];
return `\u001b[1;${suffix}${direction}`;
}
return sequences[key];
};
export const terminalControlCharacter = (value: string): string | null => {
const character = value[0]?.toUpperCase();
if (!character || character < 'A' || character > 'Z') return null;
return String.fromCharCode(character.charCodeAt(0) & 0b11111);
};
export const applyTerminalModifier = (value: string, modifier: TerminalModifier): string => {
if (!value) return value;
if (modifier === 'ctrl') return terminalControlCharacter(value) ?? value;
return value.length === 1 && value !== '\u001b' ? `\u001b${value}` : value;
};
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test';
import {
getGhosttySafeResetSequence,
rewriteGhosttyDefaultBackgroundResets,
} from './terminalOutput';
describe('terminal output compatibility', () => {
test('builds an explicit default-background reset from supported CSS colors', () => {
expect(getGhosttySafeResetSequence('#f8f7f0')).toBe('\u001b[0;48;2;248;247;240m');
expect(getGhosttySafeResetSequence('#abc')).toBe('\u001b[0;48;2;170;187;204m');
expect(getGhosttySafeResetSequence('rgb(12, 34, 56)')).toBe('\u001b[0;48;2;12;34;56m');
expect(getGhosttySafeResetSequence('var(--surface-background)')).toBeNull();
});
test('rewrites default resets even when escape sequences span chunks', () => {
const safeReset = '\u001b[0;48;2;10;20;30m';
const first = rewriteGhosttyDefaultBackgroundResets('before\u001b[', '', safeReset);
const second = rewriteGhosttyDefaultBackgroundResets('0mafter\u001b[m', first.carry, safeReset);
expect(first).toEqual({ data: 'before', carry: '\u001b[' });
expect(second).toEqual({ data: `${safeReset}after${safeReset}`, carry: '' });
});
test('preserves output when the background cannot be resolved', () => {
expect(rewriteGhosttyDefaultBackgroundResets('0m', '\u001b[', null)).toEqual({
data: '\u001b[0m',
carry: '',
});
});
});
+52
View File
@@ -0,0 +1,52 @@
// ghostty-web 0.4.0 leaves recycled rows dirty after default SGR resets (#138).
// Keep the theme background explicit until a stable release includes the upstream WASM fix.
const DEFAULT_BACKGROUND_RESETS = ['\u001b[0m', '\u001b[m'] as const;
const parseCssRgb = (color: string): [number, number, number] | null => {
const value = color.trim();
const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
if (hex) {
const expanded = hex.length <= 4
? hex.slice(0, 3).split('').map((part) => part + part).join('')
: hex.slice(0, 6);
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value);
if (!rgb) return null;
const channels = rgb.slice(1, 4).map(Number);
if (channels.some((channel) => channel < 0 || channel > 255)) return null;
return channels as [number, number, number];
};
export const getGhosttySafeResetSequence = (background: string): string | null => {
const rgb = parseCssRgb(background);
return rgb ? `\u001b[0;48;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : null;
};
export const rewriteGhosttyDefaultBackgroundResets = (
data: string,
carry: string,
safeReset: string | null,
): { data: string; carry: string } => {
const combined = carry + data;
if (!safeReset) return { data: combined, carry: '' };
let carryLength = 0;
const maxPrefixLength = Math.max(...DEFAULT_BACKGROUND_RESETS.map((reset) => reset.length)) - 1;
for (let length = 1; length <= Math.min(maxPrefixLength, combined.length); length += 1) {
const suffix = combined.slice(-length);
if (DEFAULT_BACKGROUND_RESETS.some((reset) => reset.length > suffix.length && reset.startsWith(suffix))) {
carryLength = length;
}
}
const nextCarry = carryLength > 0 ? combined.slice(-carryLength) : '';
let output = carryLength > 0 ? combined.slice(0, -carryLength) : combined;
for (const reset of DEFAULT_BACKGROUND_RESETS) output = output.replaceAll(reset, safeReset);
return { data: output, carry: nextCarry };
};
+7
View File
@@ -0,0 +1,7 @@
import type { TerminalShell } from '@/lib/api/types';
const TERMINAL_SHELL_IDS = ['bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu'] as const satisfies ReadonlyArray<Exclude<TerminalShell, 'auto'>>;
export const isTerminalShell = (value: unknown): value is TerminalShell => (
value === 'auto' || (typeof value === 'string' && TERMINAL_SHELL_IDS.includes(value as Exclude<TerminalShell, 'auto'>))
);
+1 -1
View File
@@ -77,10 +77,10 @@ export function getGhosttyTerminalOptions(
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
// TerminalViewport enables blinking only while its input owns focus.
cursorBlink: false,
cursorStyle: 'bar' as const,
fontSize,
lineHeight: 1.15,
fontFamily: augmentedFontFamily,
allowTransparency: false,
theme: {
@@ -0,0 +1,23 @@
import { describe, expect, test } from 'bun:test';
import {
getTerminalCellFromPoint,
getTerminalWordRange,
} from './terminalTouchSelection';
describe('terminal touch selection', () => {
test('maps touch points to clamped terminal cells', () => {
const bounds = { left: 20, top: 40, width: 800, height: 240 };
expect(getTerminalCellFromPoint(425, 165, bounds, 80, 24)).toEqual({ column: 40, row: 12 });
expect(getTerminalCellFromPoint(-100, 500, bounds, 80, 24)).toEqual({ column: 0, row: 23 });
expect(getTerminalCellFromPoint(20, 40, { ...bounds, width: 0 }, 80, 24)).toBeNull();
});
test('selects the non-whitespace token around a long press', () => {
expect(getTerminalWordRange(Array.from(' /projects/openchamber '), 10)).toEqual({
startColumn: 2,
endColumn: 22,
});
expect(getTerminalWordRange(Array.from('foo bar'), 3)).toEqual({ startColumn: 3, endColumn: 3 });
});
});
@@ -0,0 +1,48 @@
export type TerminalCellPosition = {
column: number;
row: number;
};
type TerminalViewportRect = {
left: number;
top: number;
width: number;
height: number;
};
export const getTerminalCellFromPoint = (
clientX: number,
clientY: number,
bounds: TerminalViewportRect,
columns: number,
rows: number,
): TerminalCellPosition | null => {
if (bounds.width <= 0 || bounds.height <= 0 || columns <= 0 || rows <= 0) return null;
const column = Math.floor(((clientX - bounds.left) / bounds.width) * columns);
const row = Math.floor(((clientY - bounds.top) / bounds.height) * rows);
return {
column: Math.max(0, Math.min(columns - 1, column)),
row: Math.max(0, Math.min(rows - 1, row)),
};
};
export const getTerminalWordRange = (
cells: string[],
column: number,
): { startColumn: number; endColumn: number } => {
const clampedColumn = Math.max(0, Math.min(cells.length - 1, column));
const isWordCell = (value: string | undefined) => Boolean(value && !/^\s+$/u.test(value));
if (!isWordCell(cells[clampedColumn])) {
return { startColumn: clampedColumn, endColumn: clampedColumn };
}
let startColumn = clampedColumn;
let endColumn = clampedColumn;
while (startColumn > 0 && isWordCell(cells[startColumn - 1])) startColumn -= 1;
while (endColumn < cells.length - 1 && isWordCell(cells[endColumn + 1])) endColumn += 1;
return { startColumn, endColumn };
};
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
const selection = {
sessionKey: 'session-1',
source: 'terminal' as const,
fileLabel: 'Terminal 1',
startLine: 4,
endLine: 5,
code: 'first\nsecond',
language: 'term-1',
text: '',
};
describe('terminal context drafts', () => {
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {} }); });
test('persists snapshots by chat session and deduplicates identical selections', () => {
useInlineCommentDraftStore.getState().addDraft(selection);
useInlineCommentDraftStore.getState().addDraft(selection);
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
expect(drafts).toHaveLength(1);
expect({ ...drafts[0], id: undefined, createdAt: undefined }).toEqual({ ...selection, id: undefined, createdAt: undefined });
});
test('supports individual removal and ordered consume', () => {
useInlineCommentDraftStore.getState().addDraft(selection);
useInlineCommentDraftStore.getState().addDraft({ ...selection, startLine: 8, endLine: 8, code: 'third' });
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
useInlineCommentDraftStore.getState().removeDraft('session-1', drafts[0].id);
expect(useInlineCommentDraftStore.getState().consumeDrafts('session-1')).toHaveLength(1);
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual([]);
});
test('restores consumed drafts after a failed send without duplicating them', () => {
useInlineCommentDraftStore.getState().addDraft(selection);
const consumed = useInlineCommentDraftStore.getState().consumeDrafts('session-1');
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual(consumed);
});
});
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal';
export interface InlineCommentDraft {
id: string;
@@ -29,6 +29,7 @@ interface InlineCommentDraftActions {
clearDrafts: (sessionKey: string) => void;
getDrafts: (sessionKey: string) => InlineCommentDraft[];
consumeDrafts: (sessionKey: string) => InlineCommentDraft[];
restoreDrafts: (sessionKey: string, drafts: InlineCommentDraft[]) => void;
getDraftCount: (sessionKey: string) => number;
hasDrafts: (sessionKey: string) => boolean;
}
@@ -36,7 +37,7 @@ interface InlineCommentDraftActions {
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
const isValidSource = (value: unknown): value is InlineCommentSource =>
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation' || value === 'terminal';
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
value === 'original' || value === 'modified';
@@ -62,6 +63,8 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
? draft.id
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const code = typeof draft.code === 'string' ? draft.code : '';
if (draft.source === 'terminal' && !code.trim()) return null;
return {
id,
sessionKey: draft.sessionKey,
@@ -70,7 +73,7 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
startLine,
endLine,
side: isValidSide(draft.side) ? draft.side : undefined,
code: typeof draft.code === 'string' ? draft.code : '',
code,
language: typeof draft.language === 'string' ? draft.language : 'text',
text: typeof draft.text === 'string' ? draft.text : '',
createdAt: Number.isFinite(draft.createdAt) ? Number(draft.createdAt) : Date.now(),
@@ -106,6 +109,7 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
drafts: {},
addDraft: (draft) => {
if (draft.source === 'terminal' && !draft.code.trim()) return;
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const newDraft: InlineCommentDraft = {
...draft,
@@ -115,6 +119,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
set((state) => {
const currentDrafts = state.drafts[draft.sessionKey] ?? [];
if (draft.source === 'terminal' && currentDrafts.some((current) => current.source === 'terminal' && current.fileLabel === draft.fileLabel && current.startLine === draft.startLine && current.endLine === draft.endLine && current.code === draft.code)) {
return state;
}
return {
drafts: {
...state.drafts,
@@ -197,6 +204,22 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
return sortedDrafts;
},
restoreDrafts: (sessionKey, drafts) => {
if (drafts.length === 0) return;
set((state) => {
const current = state.drafts[sessionKey] ?? [];
const currentIds = new Set(current.map((draft) => draft.id));
const restored = drafts.filter((draft) => draft.sessionKey === sessionKey && !currentIds.has(draft.id));
if (restored.length === 0) return state;
return {
drafts: {
...state.drafts,
[sessionKey]: [...restored, ...current].sort((a, b) => a.createdAt - b.createdAt),
},
};
});
},
getDraftCount: (sessionKey) => {
return (get().drafts[sessionKey] ?? []).length;
},
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useTerminalStore } from './useTerminalStore';
const setup = () => {
useTerminalStore.getState().clearAll();
useTerminalStore.getState().ensureDirectory('/repo');
return useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].id;
};
describe('terminal state reconciliation', () => {
afterEach(() => useTerminalStore.getState().clearAll());
test('applies snapshots atomically and deduplicates output by sequence', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' output', 5);
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' duplicate', 5);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0];
expect(tab.bufferChunks.map((chunk) => chunk.data).join('')).toBe('prompt output');
expect(tab.lastSequence).toBe(5);
});
test('keeps raw live bytes separate from replay-safe bytes', () => {
const tabId = setup();
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'prompt\u001b[6n', 1, 'prompt');
const chunk = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks[0];
expect(chunk.data).toBe('prompt\u001b[6n');
expect(chunk.replayData).toBe('prompt');
});
test('uses collision-resistant tab identities', () => {
const tabId = setup();
expect(/^tab-\d+$/.test(tabId)).toBe(false);
});
test('does not let stale snapshots replace newer output', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'new', 8);
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'stale', 7);
expect(useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks[0].data).toBe('new');
});
test('preserves buffer identity for an identical snapshot', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
const previous = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks;
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
expect(useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks).toBe(previous);
});
test('caps multibyte scrollback by UTF-8 bytes', () => {
const tabId = setup();
useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0];
expect(tab.bufferLength <= 512 * 1024).toBe(true);
expect(new TextEncoder().encode(tab.bufferChunks[0].data).byteLength).toBe(tab.bufferLength);
});
});
+85 -71
View File
@@ -1,12 +1,13 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { closeTerminal } from '@/lib/terminalApi';
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
export interface TerminalChunk {
id: number;
data: string;
replayData?: string;
byteLength: number;
}
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
@@ -19,6 +20,7 @@ export type TerminalTab = {
iconKey: string | null;
bufferChunks: TerminalChunk[];
bufferLength: number;
lastSequence: number;
isConnecting: boolean;
createdAt: number;
previewUrl: string | null;
@@ -55,13 +57,13 @@ interface TerminalStore {
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
closeTab: (directory: string, tabId: string) => Promise<void>;
closeTab: (directory: string, tabId: string) => void;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
clearBuffer: (directory: string, tabId: string) => void;
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void;
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void;
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
markPreviewAutoOpened: (directory: string, tabId: string) => void;
setProjectActionRun: (run: TerminalProjectActionRun) => void;
@@ -72,11 +74,28 @@ interface TerminalStore {
clearAll: () => void;
}
const TERMINAL_BUFFER_LIMIT = 1_000_000;
const TERMINAL_BUFFER_LIMIT = 512 * 1024;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const byteLength = (value: string): number => textEncoder.encode(value).byteLength;
const trimToBufferLimit = (value: string): string => {
const bytes = textEncoder.encode(value);
if (bytes.byteLength <= TERMINAL_BUFFER_LIMIT) return value;
let start = bytes.byteLength - TERMINAL_BUFFER_LIMIT;
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1;
return textDecoder.decode(bytes.subarray(start));
};
const TERMINAL_STORE_NAME = 'terminal-store';
let hydrationListenerAttached = false;
let fallbackTabId = 0;
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
const createTerminalTabId = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') return `tab-${globalThis.crypto.randomUUID()}`;
fallbackTabId += 1;
return `tab-${Date.now().toString(36)}-${fallbackTabId.toString(36)}`;
};
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'>;
type PersistedDirectoryTerminalState = {
tabs: PersistedTerminalTab[];
@@ -114,6 +133,7 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
iconKey: null,
bufferChunks: [],
bufferLength: 0,
lastSequence: -1,
isConnecting: false,
createdAt: Date.now(),
previewUrl: null,
@@ -149,7 +169,7 @@ export const useTerminalStore = create<TerminalStore>()(
}
const newSessions = new Map(state.sessions);
const tabId = `tab-${state.nextTabId}`;
const tabId = createTerminalTabId();
const firstTab = createEmptyTab(tabId, 'Terminal');
newSessions.set(key, createEmptyDirectoryState(firstTab));
@@ -177,7 +197,7 @@ export const useTerminalStore = create<TerminalStore>()(
return 'tab-invalid';
}
const tabId = `tab-${get().nextTabId}`;
const tabId = createTerminalTabId();
set((state) => {
const newSessions = new Map(state.sessions);
@@ -293,20 +313,8 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
closeTab: async (directory: string, tabId: string) => {
closeTab: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
const entry = get().sessions.get(key);
const tab = entry?.tabs.find((t) => t.id === tabId);
const sessionId = tab?.terminalSessionId ?? null;
if (sessionId) {
try {
await closeTerminal(sessionId);
} catch {
// ignore
}
}
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
@@ -326,7 +334,7 @@ export const useTerminalStore = create<TerminalStore>()(
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
if (nextTabs.length === 0) {
const newTabId = `tab-${state.nextTabId}`;
const newTabId = createTerminalTabId();
const newTab = createEmptyTab(newTabId, 'Terminal');
newSessions.set(key, createEmptyDirectoryState(newTab));
return {
@@ -381,7 +389,7 @@ export const useTerminalStore = create<TerminalStore>()(
terminalSessionId: sessionId,
lifecycle: nextLifecycle,
isConnecting: false,
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0, lastSequence: -1 } : {}),
};
const nextTabs = [...existing.tabs];
@@ -433,7 +441,34 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
appendToBuffer: (directory: string, tabId: string, chunk: string) => {
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => {
const key = normalizeDirectory(directory);
set((state) => {
const existing = state.sessions.get(key);
if (!existing) return state;
const idx = findTabIndex(existing, tabId);
if (idx < 0 || existing.tabs[idx].lastSequence > sequence) return state;
const retainedContent = trimToBufferLimit(content);
const bytes = byteLength(retainedContent);
const tab = existing.tabs[idx];
if (
tab.lastSequence === sequence &&
tab.bufferLength === bytes &&
tab.bufferChunks.map((chunk) => chunk.data).join('') === retainedContent
) {
return state;
}
const chunkId = state.nextChunkId;
const bufferChunks = retainedContent ? [{ id: chunkId, data: retainedContent, byteLength: bytes }] : [];
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], bufferChunks, bufferLength: bytes, lastSequence: sequence };
const sessions = new Map(state.sessions);
sessions.set(key, { ...existing, tabs: nextTabs });
return { sessions, nextChunkId: retainedContent ? chunkId + 1 : chunkId };
});
},
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => {
if (!chunk) {
return;
}
@@ -452,18 +487,28 @@ export const useTerminalStore = create<TerminalStore>()(
}
const tab = existing.tabs[idx];
if (sequence !== undefined && sequence <= tab.lastSequence) return state;
const chunkId = state.nextChunkId;
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
const retainedChunk = trimToBufferLimit(chunk);
const retainedReplayData = replayData !== undefined && replayData !== chunk
? trimToBufferLimit(replayData)
: undefined;
const chunkEntry: TerminalChunk = {
id: chunkId,
data: retainedChunk,
...(retainedReplayData !== undefined ? { replayData: retainedReplayData } : {}),
byteLength: byteLength(retainedChunk),
};
const bufferChunks = [...tab.bufferChunks, chunkEntry];
let bufferLength = tab.bufferLength + chunk.length;
let bufferLength = tab.bufferLength + chunkEntry.byteLength;
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
const removed = bufferChunks.shift();
if (!removed) {
break;
}
bufferLength -= removed.data.length;
bufferLength -= removed.byteLength;
}
const nextTabs = [...existing.tabs];
@@ -471,6 +516,7 @@ export const useTerminalStore = create<TerminalStore>()(
...tab,
bufferChunks,
bufferLength,
lastSequence: sequence ?? tab.lastSequence,
};
newSessions.set(key, { ...existing, tabs: nextTabs });
@@ -578,31 +624,6 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
clearBuffer: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...nextTabs[idx],
bufferChunks: [],
bufferLength: 0,
};
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
});
},
removeDirectory: (directory: string) => {
const key = normalizeDirectory(directory);
set((state) => {
@@ -631,8 +652,6 @@ export const useTerminalStore = create<TerminalStore>()(
id: tab.id,
label: tab.label,
iconKey: tab.iconKey,
terminalSessionId: tab.terminalSessionId,
lifecycle: tab.lifecycle,
createdAt: tab.createdAt,
})),
},
@@ -663,41 +682,35 @@ export const useTerminalStore = create<TerminalStore>()(
const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : [];
const tabs: TerminalTab[] = [];
const migratedTabIds = new Map<string, string>();
for (const rawTab of rawTabs) {
if (!isRecord(rawTab)) {
continue;
}
const id = typeof rawTab.id === 'string' ? rawTab.id : null;
if (!id) {
const persistedId = typeof rawTab.id === 'string' ? rawTab.id : null;
if (!persistedId) {
continue;
}
const num = tabIdNumber(id);
const num = tabIdNumber(persistedId);
if (num !== null) {
maxTabNum = Math.max(maxTabNum, num);
}
const terminalSessionId =
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
? (rawTab.terminalSessionId as string | null)
: null;
const lifecycleRaw = rawTab.lifecycle;
const lifecycle =
lifecycleRaw === 'idle' || lifecycleRaw === 'running' || lifecycleRaw === 'exited'
? lifecycleRaw
: (terminalSessionId ? 'running' : 'idle');
const id = num === null ? persistedId : createTerminalTabId();
migratedTabIds.set(persistedId, id);
tabs.push({
id,
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
terminalSessionId,
lifecycle,
terminalSessionId: null,
lifecycle: 'idle',
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
bufferChunks: [],
bufferLength: 0,
lastSequence: -1,
isConnecting: false,
previewUrl: null,
previewAutoOpened: false,
@@ -711,11 +724,12 @@ export const useTerminalStore = create<TerminalStore>()(
const activeTabId =
typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null;
const activeExists = activeTabId ? tabs.some((t) => t.id === activeTabId) : false;
const migratedActiveTabId = activeTabId ? (migratedTabIds.get(activeTabId) ?? activeTabId) : null;
const activeExists = migratedActiveTabId ? tabs.some((t) => t.id === migratedActiveTabId) : false;
sessions.set(directory, {
tabs,
activeTabId: activeExists ? activeTabId : tabs[0].id,
activeTabId: activeExists ? migratedActiveTabId : tabs[0].id,
});
}
+24 -2
View File
@@ -8,6 +8,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters';
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { TerminalShell } from '@/lib/api/types';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
@@ -591,6 +592,8 @@ interface UIStore {
// Global draft welcome starters; null = unset (use the default built-in set).
globalDraftStarters: DraftStarterRef[] | null;
terminalFontSize: number;
terminalShell: TerminalShell;
terminalLoginShells: TerminalShell[];
editorFontSize: number;
uiFont: UiFontOption;
monoFont: MonoFontOption;
@@ -748,6 +751,8 @@ interface UIStore {
setFontSize: (size: number) => void;
setGlobalDraftStarters: (refs: DraftStarterRef[]) => void;
setTerminalFontSize: (size: number) => void;
setTerminalShell: (shell: TerminalShell) => void;
setTerminalLoginShells: (shells: TerminalShell[]) => void;
setEditorFontSize: (size: number) => void;
setUiFont: (font: UiFontOption) => void;
setMonoFont: (font: MonoFontOption) => void;
@@ -903,7 +908,9 @@ export const useUIStore = create<UIStore>()(
messageLimit: 200,
fontSize: 100,
globalDraftStarters: null,
terminalFontSize: 13,
terminalFontSize: 14,
terminalShell: 'auto',
terminalLoginShells: [],
editorFontSize: 13,
uiFont: DEFAULT_UI_FONT,
monoFont: DEFAULT_MONO_FONT,
@@ -1671,6 +1678,14 @@ export const useUIStore = create<UIStore>()(
set({ terminalFontSize: clamped });
},
setTerminalShell: (shell) => {
set({ terminalShell: shell });
},
setTerminalLoginShells: (shells) => {
set({ terminalLoginShells: [...new Set(shells)] });
},
setEditorFontSize: (size) => {
const rounded = Math.round(size);
const clamped = Math.max(9, Math.min(32, rounded));
@@ -2206,13 +2221,18 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 10,
version: 11,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
}
const state = persistedState as Record<string, unknown>;
// v10 -> v11: move the previous terminal font default forward.
if (version < 11 && state.terminalFontSize === 13) {
state.terminalFontSize = 14;
}
// v9 -> v10: remove obsolete single-file diff view mode setting
if (version < 10) {
delete state.diffViewMode;
@@ -2343,6 +2363,8 @@ export const useUIStore = create<UIStore>()(
fontSize: state.fontSize,
globalDraftStarters: state.globalDraftStarters,
terminalFontSize: state.terminalFontSize,
terminalShell: state.terminalShell,
terminalLoginShells: state.terminalLoginShells,
editorFontSize: state.editorFontSize,
uiFont: state.uiFont,
monoFont: state.monoFont,
+13 -6
View File
@@ -9,13 +9,20 @@ import { createVSCodeActionsAPI } from './vscode';
import { createVSCodeGitHubAPI } from './github';
import { createVSCodeNotificationsAPI } from './notifications';
// Stub APIs return sensible defaults instead of throwing
const terminalUnsupported = async (): Promise<never> => {
throw new Error('Terminal is not supported in the VS Code runtime');
};
const createStubTerminalAPI = (): TerminalAPI => ({
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
listShells: terminalUnsupported,
createSession: terminalUnsupported,
connect: (_sessionId, handlers) => {
handlers.onError?.(new Error('Terminal is not supported in the VS Code runtime'), true);
return { close: () => {} };
},
sendInput: terminalUnsupported,
resize: terminalUnsupported,
close: terminalUnsupported,
});
export const createVSCodeAPIs = (): RuntimeAPIs => ({
+1
View File
@@ -108,6 +108,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber
| `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small |
| `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses |
| `OPENCHAMBER_COMPRESS_API` | Set to `true` to force `/api/*` compression, or `false` to disable it. Desktop runtime disables API compression by default to reduce local sidecar CPU use |
| `OPENCHAMBER_TERMINAL_SHELL` | Preferred terminal shell executable used by the `Auto` setting before platform defaults |
</details>
@@ -1,48 +0,0 @@
# Terminal WebSocket Transport Protocol
## Goal
Use a single persistent WebSocket for terminal input and output, while keeping the legacy SSE output route and HTTP input route as compatibility fallbacks.
## Scope
- Primary full-duplex path: WebSocket (`/api/terminal/ws`)
- Legacy output fallback: SSE (`/api/terminal/:sessionId/stream`)
- HTTP input fallback remains: `POST /api/terminal/:sessionId/input`
## Framing
- Text frame:
- client -> server: terminal keystroke payload
- server -> client: raw PTY output chunk
- Binary frame: control envelope
- Byte 0: tag (`0x01` = JSON control)
- Bytes 1..N: UTF-8 JSON payload
## Control Messages
- Bind active socket to terminal session:
- client -> server: `{"t":"b","s":"<sessionId>","v":2}`
- Keepalive ping:
- client -> server: `{"t":"p","v":2}`
- server -> client: `{"t":"po","v":2}`
- Server control responses:
- ready: `{"t":"ok","v":2}`
- bind ok: `{"t":"bok","s":"<sessionId>","runtime":"node|bun","ptyBackend":"...","v":2}`
- exit: `{"t":"x","s":"<sessionId>","exitCode":0,"signal":null}`
- error: `{"t":"e","c":"<code>","f":true|false}`
## Multiplexing Model
- Single shared socket per client runtime.
- Socket has one mutable bound session.
- Client sends a bind control when the active terminal changes.
- Text frames always apply to the currently bound session.
- PTY output is pushed back over the same socket as text frames.
- Client keeps the socket primed so both stream subscription and input reuse the same transport.
## Security
- UI auth session required when UI password is enabled.
- Origin validation enforced for cookie-authenticated browser upgrades.
- Invalid or malformed frames are rate-limited and may close the socket.
## Fallback Behavior
- New clients prefer `capabilities.stream.ws` and reuse the same socket for input.
- If stream WebSocket capability is unavailable, clients fall back to SSE output.
- If terminal input cannot be sent over WebSocket, clients fall back to HTTP input.
- The removed `/api/terminal/input-ws` path should fail with `404 Not Found`.
-3
View File
@@ -163,9 +163,6 @@ function shouldSkipCompression(req, res) {
return true;
}
if (pathname.startsWith('/api/terminal/') && pathname.endsWith('/stream')) {
return true;
}
for (const prefix of SSE_PATH_PREFIXES) {
if (pathname === prefix) {
return true;
@@ -123,7 +123,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `resolveWslExecutablePath()`
- `buildWslExecArgs(execArgs, distroOverride?)`
- `isExecutable(filePath)`
- `searchPathFor(binaryName)`
- `searchPathFor(binaryName, searchPath?)`: resolves an executable from the supplied PATH value, defaulting to the process PATH.
- `clearResolvedOpenCodeBinary()`
## Public exports (env-config.js)
@@ -87,14 +87,13 @@ export const createOpenCodeEnvRuntime = (deps) => {
return isExecutable(trimmed) ? trimmed : null;
};
const searchPathFor = (binaryName) => {
const searchPathFor = (binaryName, searchPath = process.env.PATH || '') => {
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
if (!trimmed) {
return null;
}
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
const parts = searchPath.split(path.delimiter).filter(Boolean);
const candidateNames = [];
if (process.platform === 'win32' && !path.extname(trimmed)) {
@@ -118,6 +118,19 @@ const createRuntime = (settings, options = {}) => {
};
describe('OpenCode env runtime', () => {
it('searches an explicit PATH without mutating the process environment', () => {
const defaultDir = createTempDir('openchamber-default-path-');
const explicitDir = createTempDir('openchamber-explicit-path-');
const binary = path.join(explicitDir, process.platform === 'win32' ? 'custom-shell.exe' : 'custom-shell');
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
process.env.PATH = defaultDir;
const { runtime } = createRuntime({});
expect(runtime.searchPathFor('custom-shell', explicitDir)).toBe(binary);
expect(process.env.PATH).toBe(defaultDir);
});
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
@@ -26,6 +26,7 @@ export const createSettingsHelpers = (dependencies) => {
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -547,6 +548,16 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
}
if (typeof candidate.terminalShell === 'string') {
const shell = candidate.terminalShell.trim().toLowerCase();
if (TERMINAL_SHELL_VALUES.has(shell)) result.terminalShell = shell;
}
if (Array.isArray(candidate.terminalLoginShells)) {
result.terminalLoginShells = [...new Set(candidate.terminalLoginShells
.filter((shell) => typeof shell === 'string')
.map((shell) => shell.trim().toLowerCase())
.filter((shell) => TERMINAL_SHELL_VALUES.has(shell)))];
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
}
@@ -78,6 +78,19 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
});
it('sanitizes the persisted terminal shell', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ terminalShell: ' ZSH ' })).toEqual({ terminalShell: 'zsh' });
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'auto' })).toEqual({ terminalShell: 'auto' });
expect(helpers.sanitizeSettingsUpdate({ terminalShell: '/bin/zsh' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'zsh -c whoami' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [' ZSH ', 'bash', 'zsh', '/bin/fish', 42] })).toEqual({
terminalLoginShells: ['zsh', 'bash'],
});
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [] })).toEqual({ terminalLoginShells: [] });
});
it('accepts desktopLanAccessEnabled as a persisted shared setting', () => {
const helpers = createTestHelpers();
@@ -5,6 +5,7 @@ const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
const PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version'];
const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location'];
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
const LOOPBACK_HOSTS = new Set([
'localhost',
@@ -1235,19 +1236,19 @@ export const createPreviewProxyRuntime = ({
const match = pathname.match(/^\/api\/preview\/proxy\/([a-f0-9]{16,64})(?:\/|$)/i);
const id = match?.[1] || '';
if (!id) {
return { ok: false, status: 404, error: 'Preview target not found' };
return { ok: false, status: 404, code: 'missing', error: 'Preview target not found' };
}
const entry = targets.get(id);
if (!entry || entry.expiresAt <= now()) {
targets.delete(id);
return { ok: false, status: 404, error: 'Preview target expired' };
return { ok: false, status: 404, code: 'expired', error: 'Preview target expired' };
}
const cookies = parseCookieHeader(req.headers?.cookie);
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
if (!token || token !== entry.token) {
return { ok: false, status: 403, error: 'Preview token missing' };
return { ok: false, status: 403, code: 'invalid-token', error: 'Preview token missing' };
}
return { ok: true, id, entry, parsed };
@@ -1451,6 +1452,9 @@ export const createPreviewProxyRuntime = ({
proxyReq.setHeader('accept-encoding', 'identity');
},
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
// This header is reserved for failures produced before proxying. Do
// not let an upstream application response impersonate that signal.
res.removeHeader?.(PREVIEW_TARGET_ERROR_HEADER);
applyPreviewPassthroughResponseHeaders(proxyRes, res);
// Per-response nonce lets the injected bridge run under the dev
// server's CSP without dropping its script restrictions wholesale.
@@ -1551,6 +1555,7 @@ export const createPreviewProxyRuntime = ({
app.use('/api/preview/proxy', (req, res, next) => {
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
res.setHeader(PREVIEW_TARGET_ERROR_HEADER, resolved.code);
return res.status(resolved.status).json({ error: resolved.error });
}
next();
@@ -5,12 +5,77 @@ import {
applyPreviewPassthroughResponseHeaders,
classifyPreviewNavigation,
classifyPreviewResourceError,
createPreviewProxyRuntime,
normalizeProxyTargetUrl,
PREVIEW_TARGET_ERROR_HEADER,
rewritePreviewBody,
rewritePreviewCspHeader,
rewritePreviewRedirectLocation,
} from './proxy-runtime.js';
const createResponse = () => {
const headers = new Map();
return {
body: null,
statusCode: 200,
headers,
setHeader(name, value) {
headers.set(name.toLowerCase(), value);
},
removeHeader(name) {
headers.delete(name.toLowerCase());
},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return body;
},
};
};
const createAttachedPreviewRuntime = () => {
let proxyOptions;
const postRoutes = new Map();
const useRoutes = new Map();
let randomByte = 0;
const runtime = createPreviewProxyRuntime({
crypto: {
randomBytes(size) {
randomByte += 1;
return Buffer.alloc(size, randomByte);
},
},
URL,
createProxyMiddleware(options) {
proxyOptions = options;
const middleware = () => {};
middleware.upgrade = () => {};
return middleware;
},
responseInterceptor: (handler) => handler,
});
const app = {
post(path, ...handlers) {
postRoutes.set(path, handlers);
},
use(path, ...handlers) {
useRoutes.set(path, handlers);
},
};
runtime.attach(app, {
server: { on() {} },
express: { json: () => (_req, _res, next) => next() },
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {},
});
return { postRoutes, proxyOptions: () => proxyOptions, useRoutes };
};
const rewrite = (bodyText, kind) => rewritePreviewBody({
bodyText,
kind,
@@ -18,6 +83,77 @@ const rewrite = (bodyText, kind) => rewritePreviewBody({
targetOrigin: 'http://127.0.0.1:3000',
});
describe('preview target failure signaling', () => {
it('marks missing and expired targets instead of relying on the HTTP status alone', () => {
const { useRoutes } = createAttachedPreviewRuntime();
const [guard] = useRoutes.get('/api/preview/proxy');
for (const [originalUrl, code, error] of [
['/api/preview/proxy/', 'missing', 'Preview target not found'],
[`/api/preview/proxy/${'a'.repeat(32)}/`, 'expired', 'Preview target expired'],
]) {
const response = createResponse();
guard({ originalUrl, headers: {} }, response, () => {});
expect(response.statusCode).toBe(404);
expect(response.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe(code);
expect(response.body).toEqual({ error });
}
});
it('marks invalid target tokens and accepts a registered target token', async () => {
const { postRoutes, useRoutes } = createAttachedPreviewRuntime();
const [, registerTarget] = postRoutes.get('/api/preview/targets');
const [guard] = useRoutes.get('/api/preview/proxy');
const registrationResponse = createResponse();
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
const { id, previewToken } = registrationResponse.body;
const invalidResponse = createResponse();
guard({ originalUrl: `/api/preview/proxy/${id}/`, headers: {} }, invalidResponse, () => {});
expect(invalidResponse.statusCode).toBe(403);
expect(invalidResponse.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe('invalid-token');
const validResponse = createResponse();
let continued = false;
guard({
originalUrl: `/api/preview/proxy/${id}/?oc_preview_token=${previewToken}`,
headers: {},
}, validResponse, () => {
continued = true;
});
expect(continued).toBe(true);
expect(validResponse.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
});
it('removes the reserved target-error marker from upstream responses', async () => {
const { postRoutes, proxyOptions, useRoutes } = createAttachedPreviewRuntime();
const [, registerTarget] = postRoutes.get('/api/preview/targets');
const registrationResponse = createResponse();
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
const { id, previewToken } = registrationResponse.body;
const request = {
originalUrl: `/api/preview/proxy/${id}/missing?oc_preview_token=${previewToken}`,
headers: {},
};
const response = createResponse();
response.setHeader(PREVIEW_TARGET_ERROR_HEADER, 'expired');
await proxyOptions().on.proxyRes(
Buffer.from('{"error":"upstream missing"}'),
{ headers: { 'content-type': 'application/json' } },
request,
response,
);
expect(response.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
const [guard] = useRoutes.get('/api/preview/proxy');
let continued = false;
guard(request, createResponse(), () => {
continued = true;
});
expect(continued).toBe(true);
});
});
describe('preview Inertia header passthrough', () => {
it('forwards Inertia request headers to the preview target', () => {
const forwarded = new Map();
+1 -2
View File
@@ -7,8 +7,7 @@ const isAllowedSsePath = (pathname) => {
return pathname === '/api/event'
|| pathname === '/api/global/event'
|| pathname === '/api/openchamber/events'
|| pathname === '/api/notifications/stream'
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname);
|| pathname === '/api/notifications/stream';
};
const isAllowedWebSocketPath = (pathname) => {
@@ -40,7 +40,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
Everything a client normally sends to the single OpenChamber origin:
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
- **SSE** — long-lived streamed responses (the event stream, notifications, terminal output fallback). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
@@ -58,7 +58,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback with the actual loopback origin, so normal origin checks still apply without trusting client-supplied origin metadata; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
## Candidate refresh (staying off the relay when direct works)
@@ -111,8 +111,19 @@ const startLoopbackOrigin = () =>
new Promise((resolve) => {
const server = http.createServer((req, res) => {
if (req.url === '/health') {
const expectedOrigin = `http://127.0.0.1:${server.address().port}`;
if (req.headers.origin !== expectedOrigin) {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid origin' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'stub', relayConn: req.headers['x-openchamber-relay-connection'] || null }));
res.end(JSON.stringify({
ok: true,
service: 'stub',
relayConn: req.headers['x-openchamber-relay-connection'] || null,
origin: req.headers.origin,
}));
return;
}
res.writeHead(404);
@@ -269,6 +280,7 @@ describe('relay host-client integration', () => {
expect(result.status).toBe(200);
expect(result.body.ok).toBe(true);
expect(result.body.relayConn).toBe('conn-test-1');
expect(result.body.origin).toBe(`http://127.0.0.1:${origin.port}`);
// Every forwarded frame after the two plaintext handshake frames (client
// hello, host ready) must be binary.
+7 -3
View File
@@ -141,7 +141,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
// HTTP
// -------------------------------------------------------------------------
const buildRequestHeaders = (rawHeaders) => {
const buildRequestHeaders = (rawHeaders, loopbackOrigin) => {
const headers = {};
for (const [name, value] of Object.entries(rawHeaders)) {
if (typeof name !== 'string' || typeof value !== 'string') continue;
@@ -151,6 +151,9 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
headers[lower] = value;
}
headers['x-openchamber-relay-connection'] = connectionId;
// Browser-generated Origin is not visible to the tunnel client. Present the
// loopback origin being dialed and overwrite any client-supplied value.
headers.origin = loopbackOrigin;
return headers;
};
@@ -189,12 +192,13 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
stream.noBody = true;
}
const url = `http://127.0.0.1:${getLocalPort()}${request.path}${request.query ? `?${request.query}` : ''}`;
const loopbackOrigin = `http://127.0.0.1:${getLocalPort()}`;
const url = `${loopbackOrigin}${request.path}${request.query ? `?${request.query}` : ''}`;
let response;
try {
response = await fetch(url, {
method,
headers: buildRequestHeaders(request.headers),
headers: buildRequestHeaders(request.headers, loopbackOrigin),
body: requestBody,
duplex: hasBody ? 'half' : undefined,
signal: stream.abort.signal,
@@ -1,76 +1,45 @@
# Terminal Module Documentation
# Terminal Subsystem
## Purpose
This module provides WebSocket transport utilities for terminal input and output in the web server runtime, including message normalization, control frame parsing, rate limiting, pathname resolution, and short-lived output replay buffering for terminal WebSocket connections.
## Ownership
## Entrypoints and structure
- `packages/web/server/lib/terminal/`: Terminal module directory.
- `index.js`: Stable module entrypoint that re-exports protocol helpers and replay-buffer helpers.
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
- `terminal-ws-protocol.js`: Single-file module containing terminal WebSocket protocol utilities.
- `output-replay-buffer.js`: Helper module for buffering recent terminal output so late subscribers can receive startup prompt data.
- `packages/web/server/lib/terminal/terminal-ws-protocol.test.js`: Test file for protocol utilities.
- `packages/web/server/lib/terminal/output-replay-buffer.test.js`: Test file for replay buffer helpers.
`runtime.js` owns terminal identity, PTY processes, status, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families and resolves the persisted shell ID without accepting command strings or arguments. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
## Protocol
## Public exports
`/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
### Constants
- `TERMINAL_WS_PATH`: Primary WebSocket endpoint path (`/api/terminal/ws`).
- `TERMINAL_WS_CONTROL_TAG_JSON`: Control frame tag byte (`0x01`) indicating JSON payload.
- `TERMINAL_WS_MAX_PAYLOAD_BYTES`: Maximum inbound WebSocket payload size (64KB).
- `TERMINAL_OUTPUT_REPLAY_MAX_BYTES`: Maximum buffered terminal output retained for replay (64KB).
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
- `detach` removes only that attachment.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
### Request Parsing
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
- `isTerminalWsPathname(pathname)`: Returns whether a pathname matches a supported terminal WebSocket route.
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
### Message Normalization
- `normalizeTerminalWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
- `normalizeTerminalWsMessageToText(rawData)`: Normalizes data to UTF-8 text string.
## PTY Lifecycle
### Control Frame Handling
- `readTerminalWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid or malformed.
- `createTerminalWsControlFrame(payload)`: Creates a control frame with JSON payload and prepends the control tag byte.
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
- Exited sessions remain attachable until explicit close or idle cleanup.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID.
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
### Replay Buffer Helpers
- `createTerminalOutputReplayBuffer()`: Creates mutable state for recent terminal output replay.
- `appendTerminalOutputReplayChunk(bufferState, data, maxBytes?)`: Appends a chunk, trimming older buffered data to stay within the configured byte budget.
- `listTerminalOutputReplayChunksSince(bufferState, lastSeenId)`: Returns buffered chunks newer than the provided replay cursor.
- `getLatestTerminalOutputReplayChunkId(bufferState)`: Returns the latest chunk id in the replay buffer, or `0` when empty.
## Security And Relay
### Rate Limiting
- `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded the configured threshold.
The WebSocket path must remain in both `isUrlAuthWebSocketPath` and relay `ALLOWED_WS_PATHS`. The client must use `getRuntimeUrlResolver().websocket()` and `openRuntimeWebSocket`; direct local URLs or raw browser WebSockets break relay and URL-token authentication.
## Usage in web server
The terminal helpers are used by `packages/web/server/index.js` for:
- WebSocket endpoint path definition and matching
- Message normalization for terminal input payloads
- Control frame parsing for session binding, keepalive, and exit signaling
- Rate limiting for session rebind operations
- Request pathname parsing for WebSocket routing
- Replaying startup output such as shell prompts when the client binds after the PTY already emitted data
## Verification
The web server combines these utilities with `bun-pty` or `node-pty` to drive full-duplex PTY sessions.
Run:
## Notes for contributors
- Keep control frames backward-compatible when possible; use explicit `v` values for protocol changes.
- Always normalize incoming WebSocket messages before processing them.
- Keep replay buffering small and memory-only; it exists to cover startup races, not to implement persistent scrollback.
- Add tests for new control frame types, websocket path changes, malformed payload handling, and replay trimming semantics.
- Keep HTTP input and SSE output fallbacks functional unless the rollout explicitly removes them.
## Verification notes
### Manual verification
1. Start the web server and create a terminal session via `/api/terminal/create`.
2. Wait briefly before binding the client to ensure the shell emits its prompt first.
3. Connect to `/api/terminal/ws` WebSocket and bind to the session.
4. Verify the startup prompt and early shell output are replayed before interactive input begins.
5. Verify `/api/terminal/input-ws` is rejected with `404 Not Found` and `/api/terminal/:sessionId/stream` still works as a fallback path.
### Automated verification
- Run `bun test packages/web/server/lib/terminal/terminal-ws-protocol.test.js`
- Run `bun test packages/web/server/lib/terminal/output-replay-buffer.test.js`
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
```sh
bun test packages/web/server/lib/terminal/runtime.test.js packages/web/server/lib/terminal/terminal-ws-protocol.test.js
bun test packages/web/server/lib/ui-auth/ui-auth.test.js packages/web/server/lib/relay/cross-compat.test.js
```
@@ -0,0 +1,83 @@
const isCsiFinalByte = (code) => code >= 0x40 && code <= 0x7e;
const shouldStripCsi = (body, finalByte) =>
finalByte === 'n'
|| (finalByte === 'R' && /^[0-9;?]*$/.test(body))
|| (finalByte === 'c' && /^[>0-9;?]*$/.test(body))
|| ((finalByte === 'p' || finalByte === 'y') && /^\?2031(?:;[0-9]+)?\$$/.test(body))
|| ((finalByte === 'h' || finalByte === 'l') && body === '?2031');
const shouldStripOsc = (content) => /^(10|11|12);(?:\?|rgb:)/.test(content);
const stripTerminator = (value) => {
if (value.endsWith('\u001b\\')) return value.slice(0, -2);
return value.endsWith('\u0007') || value.endsWith('\u009c') ? value.slice(0, -1) : value;
};
const findStringEnd = (input, start) => {
for (let index = start; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code === 0x07 || code === 0x9c) return index + 1;
if (code === 0x1b && input.charCodeAt(index + 1) === 0x5c) return index + 2;
}
return null;
};
const findEscapeEnd = (input, start) => {
let cursor = start;
while (cursor < input.length && input.charCodeAt(cursor) >= 0x20 && input.charCodeAt(cursor) <= 0x2f) cursor += 1;
if (cursor >= input.length) return null;
return input.charCodeAt(cursor) >= 0x30 && input.charCodeAt(cursor) <= 0x7e ? cursor + 1 : start + 1;
};
export const sanitizeTerminalHistoryChunk = (pending, data) => {
const input = `${pending}${data}`;
let visible = '';
let index = 0;
while (index < input.length) {
const code = input.charCodeAt(index);
if (code === 0x1b) {
const next = input.charCodeAt(index + 1);
if (Number.isNaN(next)) return { visible, pending: input.slice(index) };
if (next === 0x5b) {
let cursor = index + 2;
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
if (cursor >= input.length) return { visible, pending: input.slice(index) };
const sequence = input.slice(index, cursor + 1);
if (!shouldStripCsi(input.slice(index + 2, cursor), input[cursor])) visible += sequence;
index = cursor + 1;
continue;
}
if (next === 0x5d || next === 0x50 || next === 0x5e || next === 0x5f) {
const end = findStringEnd(input, index + 2);
if (end === null) return { visible, pending: input.slice(index) };
const sequence = input.slice(index, end);
const content = stripTerminator(input.slice(index + 2, end));
if (next !== 0x5d || !shouldStripOsc(content)) visible += sequence;
index = end;
continue;
}
const end = findEscapeEnd(input, index + 1);
if (end === null) return { visible, pending: input.slice(index) };
visible += input.slice(index, end);
index = end;
continue;
}
if (code === 0x9b) {
let cursor = index + 1;
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
if (cursor >= input.length) return { visible, pending: input.slice(index) };
const sequence = input.slice(index, cursor + 1);
if (!shouldStripCsi(input.slice(index + 1, cursor), input[cursor])) visible += sequence;
index = cursor + 1;
continue;
}
if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) {
const end = findStringEnd(input, index + 1);
if (end === null) return { visible, pending: input.slice(index) };
const sequence = input.slice(index, end);
const content = stripTerminator(input.slice(index + 1, end));
if (code !== 0x9d || !shouldStripOsc(content)) visible += sequence;
index = end;
continue;
}
visible += input[index];
index += 1;
}
return { visible, pending: '' };
};
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { sanitizeTerminalHistoryChunk } from './history.js';
describe('terminal replay history', () => {
it('removes device and color query exchanges while preserving display controls', () => {
const input = `before\u001b[6n\u001b[12;40R\u001b[>0c\u001b[?2031h\u001b[?2031$p\u001b[?2031;1$y\u001b]10;?\u0007\u001b[31mred\u001b[0mafter`;
expect(sanitizeTerminalHistoryChunk('', input)).toEqual({ visible: 'before\u001b[31mred\u001b[0mafter', pending: '' });
});
it('carries incomplete control sequences across PTY chunks', () => {
const first = sanitizeTerminalHistoryChunk('', 'text\u001b]11;');
expect(first).toEqual({ visible: 'text', pending: '\u001b]11;' });
expect(sanitizeTerminalHistoryChunk(first.pending, '?\u001b\\next')).toEqual({ visible: 'next', pending: '' });
});
it('preserves ordinary OSC titles and split UTF-16 text', () => {
expect(sanitizeTerminalHistoryChunk('', '\u001b]0;title\u0007ok')).toEqual({ visible: '\u001b]0;title\u0007ok', pending: '' });
});
});
@@ -1,78 +0,0 @@
export const TERMINAL_OUTPUT_REPLAY_MAX_BYTES = 64 * 1024;
const trimTerminalOutputChunkToMaxBytes = (data, maxBytes) => {
if (typeof data !== 'string' || data.length === 0) {
return '';
}
const bytes = Buffer.byteLength(data, 'utf8');
if (bytes <= maxBytes) {
return data;
}
const kept = [];
let trimmedBytes = 0;
const characters = Array.from(data);
for (let index = characters.length - 1; index >= 0; index -= 1) {
const character = characters[index];
const characterBytes = Buffer.byteLength(character, 'utf8');
if (trimmedBytes + characterBytes > maxBytes) {
break;
}
kept.push(character);
trimmedBytes += characterBytes;
}
return kept.reverse().join('');
};
export const createTerminalOutputReplayBuffer = () => ({
chunks: [],
totalBytes: 0,
nextId: 1,
});
export const appendTerminalOutputReplayChunk = (bufferState, data, maxBytes = TERMINAL_OUTPUT_REPLAY_MAX_BYTES) => {
if (!bufferState || typeof bufferState !== 'object') {
return null;
}
const normalizedData = trimTerminalOutputChunkToMaxBytes(data, maxBytes);
if (!normalizedData) {
return null;
}
const bytes = Buffer.byteLength(normalizedData, 'utf8');
const chunk = {
id: bufferState.nextId,
data: normalizedData,
bytes,
};
bufferState.nextId += 1;
bufferState.chunks.push(chunk);
bufferState.totalBytes += bytes;
while (bufferState.totalBytes > maxBytes && bufferState.chunks.length > 1) {
const removedChunk = bufferState.chunks.shift();
bufferState.totalBytes -= removedChunk?.bytes ?? 0;
}
return chunk;
};
export const listTerminalOutputReplayChunksSince = (bufferState, lastSeenId = 0) => {
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks)) {
return [];
}
return bufferState.chunks.filter((chunk) => chunk.id > lastSeenId);
};
export const getLatestTerminalOutputReplayChunkId = (bufferState) => {
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks) || bufferState.chunks.length === 0) {
return 0;
}
return bufferState.chunks[bufferState.chunks.length - 1]?.id ?? 0;
};
@@ -1,75 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
appendTerminalOutputReplayChunk,
createTerminalOutputReplayBuffer,
getLatestTerminalOutputReplayChunkId,
listTerminalOutputReplayChunksSince,
} from './output-replay-buffer.js';
describe('terminal output replay buffer', () => {
it('starts empty', () => {
const bufferState = createTerminalOutputReplayBuffer();
expect(bufferState).toEqual({ chunks: [], totalBytes: 0, nextId: 1 });
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(0);
});
it('appends chunks with incrementing ids', () => {
const bufferState = createTerminalOutputReplayBuffer();
const first = appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
const second = appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
expect(first).toEqual({ id: 1, data: 'prompt> ', bytes: 8 });
expect(second).toEqual({ id: 2, data: 'ls\r\n', bytes: 4 });
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(2);
});
it('lists chunks after a replay cursor', () => {
const bufferState = createTerminalOutputReplayBuffer();
appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
appendTerminalOutputReplayChunk(bufferState, 'file.txt\r\n');
expect(listTerminalOutputReplayChunksSince(bufferState, 1).map((chunk) => chunk.data)).toEqual([
'ls\r\n',
'file.txt\r\n',
]);
});
it('trims old chunks beyond max bytes', () => {
const bufferState = createTerminalOutputReplayBuffer();
appendTerminalOutputReplayChunk(bufferState, '1234', 8);
appendTerminalOutputReplayChunk(bufferState, '5678', 8);
appendTerminalOutputReplayChunk(bufferState, '90', 8);
expect(bufferState.chunks.map((chunk) => chunk.data)).toEqual(['5678', '90']);
expect(bufferState.totalBytes).toBe(6);
});
it('trims oversized single chunks to the configured max bytes', () => {
const bufferState = createTerminalOutputReplayBuffer();
const chunk = appendTerminalOutputReplayChunk(bufferState, 'abcdefghij', 4);
expect(chunk?.data).toBe('ghij');
expect(chunk?.bytes).toBe(4);
expect(bufferState.totalBytes).toBe(4);
});
it('does not split multibyte characters when trimming oversized chunks', () => {
const bufferState = createTerminalOutputReplayBuffer();
const chunk = appendTerminalOutputReplayChunk(bufferState, '🙂x', 2);
expect(chunk?.data).toBe('x');
expect(chunk?.bytes).toBe(1);
expect(bufferState.totalBytes).toBe(1);
});
it('uses the default max bytes when not provided', () => {
const bufferState = createTerminalOutputReplayBuffer();
const chunk = appendTerminalOutputReplayChunk(bufferState, 'ok');
expect(chunk?.bytes).toBe(2);
expect(TERMINAL_OUTPUT_REPLAY_MAX_BYTES).toBe(64 * 1024);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,13 @@
import { EventEmitter } from 'node:events';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import express from 'express';
import { WebSocket } from 'ws';
import { createTerminalRuntime } from './runtime.js';
import { createTerminalWsControlFrame, readTerminalWsControlFrame } from './terminal-ws-protocol.js';
function createResponse() {
return {
@@ -47,6 +51,53 @@ function createRuntime(server, overrides = {}) {
}
describe('terminal runtime', () => {
const createHarness = (overrides = {}) => {
const routes = { get: new Map(), post: new Map(), delete: new Map() };
const processes = [];
const app = {
post(route, handler) { routes.post.set(route, handler); },
get(route, handler) { routes.get.set(route, handler); },
delete(route, handler) { routes.delete.set(route, handler); },
};
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: (shell, args, options) => {
const dataHandlers = new Set();
const exitHandlers = new Set();
const process = {
pid: 123 + processes.length,
shell,
args,
options,
writes: [],
resizes: [],
killed: false,
kills: [],
write(data) { this.writes.push(data); },
resize(cols, rows) { this.resizes.push([cols, rows]); },
kill(signal) { this.killed = true; this.kills.push(signal ?? 'SIGTERM'); },
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
onExit(handler) { exitHandlers.add(handler); return { dispose: () => exitHandlers.delete(handler) }; },
emitData(data) { for (const handler of dataHandlers) handler(data); },
emitExit(exitCode = 0, signal = 0) { for (const handler of exitHandlers) handler({ exitCode, signal }); },
};
processes.push(process);
return process;
},
});
const server = new EventEmitter();
const runtime = createRuntime(server, {
app,
loadPtyProvider,
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
...overrides,
});
return { routes, processes, runtime };
};
it('rejects regular files as terminal working directories', async () => {
const postRoutes = new Map();
const app = {
@@ -93,4 +144,386 @@ describe('terminal runtime', () => {
expect(server.listenerCount('upgrade')).toBe(0);
});
it('creates client-identified sessions and forwards bounded resize operations', async () => {
const harness = createHarness();
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
expect(appearance.body).toEqual({ success: true });
expect(harness.processes[0].writes.at(-1)).toBe('\u001b[?997;1n');
const resize = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 200, rows: 60 } }, resize);
expect(resize.statusCode).toBe(200);
expect(harness.processes[0].resizes).toEqual([[200, 60]]);
const invalid = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 1001, rows: 60 } }, invalid);
expect(invalid.statusCode).toBe(400);
} finally { await harness.runtime.shutdown(); }
});
it('lists available shells and uses the selected shell for create and restart', async () => {
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/zsh\n/bin/bash\n/bin/false\n',
},
},
searchPathFor: (name) => executables.has(`/bin/${name}`) ? `/bin/${name}` : null,
isExecutable: (candidate) => executables.has(candidate),
});
try {
const listed = createResponse();
await harness.routes.get.get('/api/terminal/shells')({}, listed);
expect(listed.body).toEqual(expect.arrayContaining([
{ id: 'auto', name: 'Auto', supportsLogin: true },
{ id: 'zsh', name: 'zsh', supportsLogin: true },
{ id: 'bash', name: 'bash', supportsLogin: true },
{ id: 'sh', name: 'sh', supportsLogin: false },
]));
const created = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
expect(created.statusCode).toBe(200);
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
expect(restarted.statusCode).toBe(200);
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid and unavailable explicit shells', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
for (const [shell, error] of [
['zsh -c whoami', 'Invalid terminal shell'],
['fish', 'Terminal shell "fish" is not available'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell } }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid and unsupported login modes', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
for (const [loginShell, error] of [
['true', 'Invalid terminal login mode'],
[true, 'Terminal shell "sh" does not support login mode'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell: 'sh', loginShell } }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('preserves the running process when a replacement shell is unavailable', async () => {
const harness = createHarness({
fs: {
promises: {
stat: async () => ({ isDirectory: () => true }),
readFile: async () => '/bin/sh\n',
},
},
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
isExecutable: (candidate) => candidate === '/bin/sh',
});
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', shell: 'sh' } }, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { shell: 'fish' } }, restarted);
expect(restarted.statusCode).toBe(400);
expect(restarted.body.error).toBe('Terminal shell "fish" is not available');
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(false);
} finally { await harness.runtime.shutdown(); }
});
it('deduplicates concurrent creates and rejects cross-directory id reuse', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const second = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, second),
]);
expect(harness.processes).toHaveLength(1);
expect(first.body.sessionId).toBe('term-shared');
expect(second.body.sessionId).toBe('term-shared');
const conflicting = createResponse();
await create({ body: { sessionId: 'term-shared', cwd: '/other' } }, conflicting);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session belongs to a different working directory');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects concurrent creates with conflicting shell preferences', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflicting = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto' } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'zsh' } }, conflicting),
]);
expect(first.statusCode).toBe(200);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session is already being created with a different shell');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects concurrent creates with conflicting login modes', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflicting = createResponse();
await Promise.all([
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: false } }, first),
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: true } }, conflicting),
]);
expect(first.statusCode).toBe(200);
expect(conflicting.statusCode).toBe(400);
expect(conflicting.body.error).toBe('Terminal session is already being created with a different login mode');
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('restarts atomically with the same identity and closes the previous process', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { cwd: '/other', cols: 90, rows: 30 } }, restarted);
expect(restarted.body).toEqual({ sessionId: 'term-1', cols: 90, rows: 30, status: 'running' });
expect(harness.processes).toHaveLength(2);
expect(harness.processes[0].killed).toBe(true);
expect(harness.processes[1].options.cwd).toBe('/other');
} finally { await harness.runtime.shutdown(); }
});
it('serializes concurrent restarts without orphaning replacement processes', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const restart = harness.routes.post.get('/api/terminal/:sessionId/restart');
await create({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
const first = createResponse();
const second = createResponse();
await Promise.all([
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/first' } }, first),
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/second' } }, second),
]);
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(harness.processes).toHaveLength(3);
expect(harness.processes[0].killed).toBe(true);
expect(harness.processes[1].killed).toBe(true);
expect(harness.processes[2].killed).toBe(false);
expect(harness.processes[2].options.cwd).toBe('/second');
} finally { await harness.runtime.shutdown(); }
});
it('retains exited sessions until explicit close', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
harness.processes[0].emitData('last output');
harness.processes[0].emitExit(7, 0);
const resize = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 80, rows: 24 } }, resize);
expect(resize.statusCode).toBe(200);
const closed = createResponse();
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, closed);
expect(closed.body).toEqual({ success: true });
} finally { await harness.runtime.shutdown(); }
});
it('escalates close to SIGKILL when a running process ignores SIGTERM', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, createResponse());
expect(harness.processes[0].kills).toEqual(['SIGTERM', 'SIGKILL']);
} finally { await harness.runtime.shutdown(); }
});
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
const app = express();
app.use(express.json());
const server = http.createServer(app);
const processes = [];
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: () => {
const data = new Set();
const exits = new Set();
const process = {
pid: 99123,
killed: false,
writes: [],
write(value) { this.writes.push(value); }, resize() {}, kill() { this.killed = true; },
onData(handler) { data.add(handler); return { dispose: () => data.delete(handler) }; },
onExit(handler) { exits.add(handler); return { dispose: () => exits.delete(handler) }; },
emitData(value) { for (const handler of data) handler(value); },
emitExit(exitCode) { for (const handler of exits) handler({ exitCode, signal: 0 }); },
};
processes.push(process);
return process;
},
});
const runtime = createRuntime(server, {
app, loadPtyProvider,
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh', isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
const sockets = [];
const open = async () => {
const socket = new WebSocket(socketUrl);
sockets.push(socket);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
};
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-live', cwd: '/repo', cols: 80, rows: 24 }),
});
expect(created.status).toBe(200);
const secondCreated = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-second', cwd: '/other', cols: 80, rows: 24 }),
});
expect(secondCreated.status).toBe(200);
const first = await open();
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(processes[0].writes).toEqual(['echo ok\r', 'echo next\r']);
expect(processes[1].writes).toEqual(['pwd\r']);
processes[1].emitData('/other\r\n');
expect(await first.next('output', 'term-second')).toMatchObject({ s: 'term-second', q: 1, d: '/other\r\n' });
first.socket.send(createTerminalWsControlFrame({ t: 'detach', v: 3, s: 'term-second' }));
await new Promise((resolve) => setTimeout(resolve, 5));
processes[1].emitData('detached\r\n');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(first.messages.some((message) => message?.t === 'output' && message.s === 'term-second')).toBe(false);
processes[0].emitData('ok\r\n');
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 1, d: 'ok\r\n' });
processes[0].emitData('\u001b[6n');
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 2, d: '\u001b[6n', r: '' });
const secondClosed = await fetch(`${base}/api/terminal/term-second`, { method: 'DELETE' });
expect(secondClosed.status).toBe(200);
first.socket.close();
const second = await open();
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
processes[0].emitExit(7);
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
const closed = await fetch(`${base}/api/terminal/term-live`, { method: 'DELETE' });
expect(closed.status).toBe(200);
expect(await second.next('error')).toMatchObject({ s: 'term-live', code: 'CLOSED', fatal: true });
await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'term-kill', cwd: '/repo' }),
});
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-kill' }));
await second.next('snapshot');
const killed = await fetch(`${base}/api/terminal/force-kill`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cwd: '/repo' }),
});
expect(await killed.json()).toEqual({ success: true, killedCount: 1, killedSessionIds: ['term-kill'] });
expect(await second.next('error')).toMatchObject({ s: 'term-kill', code: 'KILLED', fatal: true });
expect(processes[2].killed).toBe(true);
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
});
+100
View File
@@ -0,0 +1,100 @@
const TERMINAL_SHELL_IDS = ['bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu'];
const TERMINAL_SHELL_ID_SET = new Set(TERMINAL_SHELL_IDS);
export const normalizeTerminalShell = (value) => {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return normalized === 'auto' || TERMINAL_SHELL_ID_SET.has(normalized) ? normalized : null;
};
const shellIdFromPath = (value) => {
const filename = String(value || '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() || '';
const id = filename.endsWith('.exe') ? filename.slice(0, -4) : filename;
return TERMINAL_SHELL_ID_SET.has(id) ? id : null;
};
const SHELL_LABELS = { pwsh: 'PowerShell', powershell: 'Windows PowerShell', cmd: 'Command Prompt' };
const shellLabel = (id) => SHELL_LABELS[id] ?? id;
export const getTerminalShellLoginArgs = (executable, platform = process.platform) => {
const id = shellIdFromPath(executable);
if (id === 'bash' || id === 'zsh' || id === 'ksh') return ['-l'];
if (id === 'fish' || id === 'nu') return ['--login'];
if (id === 'pwsh' && platform !== 'win32') return ['-Login'];
return null;
};
export const createTerminalShellResolver = ({ fs, path, searchPathFor, isExecutable, buildAugmentedPath = () => env.PATH || '', platform = process.platform, env = process.env }) => {
const resolveExecutable = (candidate) => {
if (!candidate) return null;
const value = String(candidate);
const found = value.includes('/') || value.includes('\\') ? value : searchPathFor(value, buildAugmentedPath());
if (found && isExecutable(found)) return found;
return isExecutable(value) ? value : null;
};
const defaultCandidates = () => platform === 'win32'
? [
env.OPENCHAMBER_TERMINAL_SHELL,
env.SHELL,
env.ComSpec,
path.join(env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
'pwsh.exe',
'powershell.exe',
'cmd.exe',
]
: [env.OPENCHAMBER_TERMINAL_SHELL, env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh', 'zsh', 'bash', 'sh'];
const resolveCandidates = (candidates) => {
const seen = new Set();
return candidates
.map(resolveExecutable)
.filter((candidate) => candidate && !seen.has(candidate) && seen.add(candidate));
};
const list = async () => {
let configuredShells = [];
if (platform !== 'win32') {
try {
const contents = await fs.promises.readFile('/etc/shells', 'utf8');
configuredShells = contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
} catch {
// PATH and platform defaults below remain authoritative fallbacks.
}
}
const candidates = platform === 'win32'
? [...defaultCandidates(), ...TERMINAL_SHELL_IDS]
: [env.OPENCHAMBER_TERMINAL_SHELL, env.SHELL, ...configuredShells, ...TERMINAL_SHELL_IDS, '/bin/zsh', '/bin/bash', '/bin/sh'];
const autoExecutable = resolveCandidates(defaultCandidates())[0] ?? null;
const byId = new Map([
['auto', {
id: 'auto',
name: 'Auto',
executable: autoExecutable,
supportsLogin: Boolean(autoExecutable && getTerminalShellLoginArgs(autoExecutable, platform)),
}],
]);
for (const executable of resolveCandidates(candidates)) {
const id = shellIdFromPath(executable);
if (id && !byId.has(id)) {
byId.set(id, { id, name: shellLabel(id), executable, supportsLogin: Boolean(getTerminalShellLoginArgs(executable, platform)) });
}
}
return [...byId.values()];
};
const resolve = async (preference) => {
const normalized = normalizeTerminalShell(preference ?? 'auto');
if (!normalized) throw new Error('Invalid terminal shell');
if (normalized === 'auto') return { id: 'auto', executables: resolveCandidates(defaultCandidates()) };
const selected = (await list()).find((shell) => shell.id === normalized);
if (!selected) throw new Error(`Terminal shell "${normalized}" is not available`);
return { id: selected.id, executables: [selected.executable] };
};
return { list, resolve };
};
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { createTerminalShellResolver, getTerminalShellLoginArgs } from './shells.js';
const createResolver = ({ platform = 'linux', env = {}, augmentedPath = '/augmented/bin', executables = [] } = {}) => {
const available = new Set(executables);
const path = {
delimiter: platform === 'win32' ? ';' : ':',
extname: (value) => /\.[^./\\]+$/.exec(value)?.[0] ?? '',
join: (...parts) => parts.join(platform === 'win32' ? '\\' : '/'),
};
const searches = [];
return {
searches,
resolver: createTerminalShellResolver({
fs: { promises: { readFile: async () => '' } },
path,
platform,
env,
buildAugmentedPath: () => augmentedPath,
searchPathFor: (name, searchPath) => {
searches.push([name, searchPath]);
const suffixes = platform === 'win32' ? ['', '.exe'] : [''];
for (const suffix of suffixes) {
const match = [...available].find((candidate) => candidate.toLowerCase().endsWith(`${platform === 'win32' ? '\\' : '/'}${name}${suffix}`.toLowerCase()));
if (match) return match;
}
return null;
},
isExecutable: (candidate) => available.has(candidate),
}),
};
};
describe('terminal shell resolver', () => {
it('discovers shells from the augmented PTY PATH', async () => {
const { resolver, searches } = createResolver({ executables: ['/augmented/bin/fish'] });
await expect(resolver.list()).resolves.toContainEqual({ id: 'fish', name: 'fish', executable: '/augmented/bin/fish', supportsLogin: true });
expect(searches).toContainEqual(['fish', '/augmented/bin']);
});
it('discovers supported PATH-installed shells on Windows', async () => {
const { resolver } = createResolver({
platform: 'win32',
augmentedPath: 'C:\\Tools',
executables: ['C:\\Tools\\bash.exe', 'C:\\Tools\\nu.exe'],
});
await expect(resolver.list()).resolves.toEqual(expect.arrayContaining([
{ id: 'bash', name: 'bash', executable: 'C:\\Tools\\bash.exe', supportsLogin: true },
{ id: 'nu', name: 'nu', executable: 'C:\\Tools\\nu.exe', supportsLogin: true },
]));
});
it('uses environment overrides before platform defaults for auto', async () => {
const { resolver } = createResolver({
env: { OPENCHAMBER_TERMINAL_SHELL: '/custom/zsh', SHELL: '/bin/bash' },
executables: ['/custom/zsh', '/bin/bash'],
});
await expect(resolver.resolve('auto')).resolves.toEqual({ id: 'auto', executables: ['/custom/zsh', '/bin/bash'] });
});
it('uses only known platform-safe login arguments', () => {
expect(getTerminalShellLoginArgs('/bin/bash', 'linux')).toEqual(['-l']);
expect(getTerminalShellLoginArgs('/opt/homebrew/bin/fish', 'darwin')).toEqual(['--login']);
expect(getTerminalShellLoginArgs('/usr/bin/nu', 'linux')).toEqual(['--login']);
expect(getTerminalShellLoginArgs('/usr/bin/pwsh', 'linux')).toEqual(['-Login']);
expect(getTerminalShellLoginArgs('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'win32')).toBeNull();
expect(getTerminalShellLoginArgs('/bin/dash', 'linux')).toBeNull();
});
});
@@ -0,0 +1,74 @@
const MODE_SET = '\u001b[?2031h';
const MODE_RESET = '\u001b[?2031l';
const CAPABILITY_QUERY = '\u001b[?2031$p';
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
const OSC_QUERIES = [10, 11].flatMap((code) => [
{ sequence: `\u001b]${code};?\u0007`, code },
{ sequence: `\u001b]${code};?\u001b\\`, code },
]);
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
const parseColor = (value) => {
if (typeof value !== 'string') return null;
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map((part) => part + part).join('') : hex;
return [0, 2, 4].map((offset) => Number.parseInt(expanded.slice(offset, offset + 2), 16));
}
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
return rgb ? rgb.slice(1, 4).map((part) => Math.min(255, Number(part))) : null;
};
const colorReport = (code, color) => {
const rgb = parseColor(color);
if (!rgb) return null;
const channels = rgb.map((channel) => channel.toString(16).padStart(2, '0').repeat(2));
return `\u001b]${code};rgb:${channels.join('/')}\u001b\\`;
};
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
const input = `${pending}${data}`;
const responses = [];
let modeEnabled = appearance.modeEnabled === true;
for (let index = 0; index < input.length; index += 1) {
if (input.startsWith(MODE_SET, index)) {
modeEnabled = true;
index += MODE_SET.length - 1;
continue;
}
if (input.startsWith(MODE_RESET, index)) {
modeEnabled = false;
index += MODE_RESET.length - 1;
continue;
}
if (input.startsWith(CAPABILITY_QUERY, index)) {
responses.push(`\u001b[?2031;${modeEnabled ? 1 : 2}$y`);
index += CAPABILITY_QUERY.length - 1;
continue;
}
const modeQuery = MODE_QUERIES.find((query) => input.startsWith(query, index));
if (modeQuery) {
responses.push(terminalThemeModeReport(appearance.themeMode));
index += modeQuery.length - 1;
continue;
}
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
if (oscQuery) {
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
if (response) responses.push(response);
index += oscQuery.sequence.length - 1;
}
}
let nextPending = '';
const maxLength = Math.max(...CONTROL_SEQUENCES.map((sequence) => sequence.length));
for (let length = 1; length < Math.min(input.length + 1, maxLength); length += 1) {
const suffix = input.slice(-length);
if (CONTROL_SEQUENCES.some((sequence) => sequence.length > length && sequence.startsWith(suffix))) nextPending = suffix;
}
return { pending: nextPending, responses, modeEnabled };
};
@@ -0,0 +1,47 @@
import { describe, expect, test } from 'vitest';
import { consumeTerminalThemeQueries } from './theme-response.js';
const lightAppearance = {
themeMode: 'light',
foreground: '#1b1b1b',
background: '#faf8f0',
modeEnabled: false,
};
describe('terminal theme responses', () => {
test('answers the complete OpenTUI startup handshake', () => {
const result = consumeTerminalThemeQueries(
'',
'\u001b[?2031h\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[?2031$p',
lightAppearance,
);
expect(result).toEqual({
pending: '',
modeEnabled: true,
responses: [
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
'\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\',
'\u001b[?2031;1$y',
],
});
});
test('handles a query split across PTY output chunks without duplicating it', () => {
const first = consumeTerminalThemeQueries('', '\u001b]11;', { ...lightAppearance, themeMode: 'dark' });
const second = consumeTerminalThemeQueries(first.pending, '?\u001b\\', { ...lightAppearance, themeMode: 'dark', modeEnabled: first.modeEnabled });
const third = consumeTerminalThemeQueries(second.pending, 'x', { ...lightAppearance, themeMode: 'dark', modeEnabled: second.modeEnabled });
expect(second.responses).toEqual(['\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
expect(second.pending).toBe('');
expect(third.responses).toEqual([]);
});
test('answers every repeated query in wire order', () => {
const result = consumeTerminalThemeQueries('', '\u001b[?996n\u001b[?996n\u001b]10;?\u0007\u001b]10;?\u0007', lightAppearance);
expect(result.responses).toEqual([
'\u001b[?997;2n',
'\u001b[?997;2n',
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
]);
});
});

Some files were not shown because too many files have changed in this diff Show More