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
+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 lespace 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 };
};