Compare commits

...

7 Commits

Author SHA1 Message Date
陈大猫
41fea1028d Merge pull request #1380 from binaricat/fix/issue-1363-nerd-font-glyphs-on-startup
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
fix(terminal): refresh Nerd Font glyphs on cold start (fixes #1363)
2026-06-10 19:59:13 +08:00
bincxz
5a90a4331b fix(terminal): refresh Nerd Font glyphs after bundled fonts load
Cold-start local terminals on Linux could cache Powerline icon tofu when the
shell prompt arrived before Symbols Nerd Font Mono finished loading. Preload
the icon fallback at the active cell size and clear the xterm atlas so
already-drawn prompt glyphs re-rasterize (fixes #1363).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:55:46 +08:00
秋秋
881f3b1a34 feat(et): support server stats for EternalTerminal sessions (#1377)
* feat(et): support server stats for EternalTerminal sessions

- Generalize the Mosh stats companion into reusable connection helpers
- Open a companion SSH connection so the host-info bar works for ET sessions
- Fall back to execOnEtSession for jumped ET sessions without a direct connection
- Forward host-key and algorithm options to the ET backend for companion parity
- Close the ET stats companion on session close, cleanup, and PTY exit

* fix(et): harden stats exec host-key trust and cleanup

Enforce StrictHostKeyChecking=yes for background ET stats/distro probes
instead of accept-new, merge vault known_hosts for parity with ssh2
companions, and wrap companion connection teardown in try/catch.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:46:24 +08:00
陈大猫
8be5865b76 fix(terminal): isolate workspace pane font zoom from global settings (#1379)
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:38:39 +08:00
陈大猫
685d1cb41a Merge pull request #1378 from binaricat/fix/issue-1375-shell-only-tab-numbers
feat(shortcuts): add option to skip pinned tabs for number keys
2026-06-10 19:15:48 +08:00
bincxz
14fe1e3ecb feat(shortcuts): add option to skip pinned tabs for number keys
Fixes #1375 by letting Cmd/Ctrl+[1...9] target only work tabs when enabled, while keeping the existing Terminus-style default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:15:15 +08:00
陈大猫
636f4d7037 fix(terminal): reflow workspace when compose bar or side panel layout changes (#1376)
Keep the compose bar inside the terminal workspace so SFTP side panels stay full height, refit xterm when the bar toggles, and remeasure split-pane geometry when side panels open or close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:00:46 +08:00
44 changed files with 944 additions and 203 deletions

View File

@@ -229,6 +229,8 @@ function App({ settings }: { settings: SettingsState }) {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
@@ -985,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}

View File

@@ -4,6 +4,7 @@ import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
@@ -444,13 +445,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
const numberShortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
orderedTabs,
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
if (num <= numberShortcutTabs.length) {
setActiveTabId(numberShortcutTabs[num - 1]);
}
}
break;

View File

@@ -45,7 +45,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
@@ -254,6 +254,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateSessionFontSize={updateSessionFontSize}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});

View File

@@ -0,0 +1,14 @@
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...pinnedTabs, ...workTabs];
}

View File

@@ -468,6 +468,8 @@ export const enCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',

View File

@@ -468,6 +468,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',

View File

@@ -311,6 +311,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',

View File

@@ -64,6 +64,7 @@ export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;

View File

@@ -28,6 +28,7 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -77,6 +78,7 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -112,6 +114,7 @@ interface UseSettingsStorageSyncParams {
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -133,7 +136,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -142,7 +145,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -156,7 +159,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
@@ -166,7 +169,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
@@ -380,6 +383,12 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
setShellOnlyTabNumberShortcutsState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -448,6 +457,7 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,

View File

@@ -43,6 +43,8 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};

View File

@@ -16,6 +16,7 @@ SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -72,6 +73,18 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
)));
}, []);
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
)));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
@@ -943,6 +956,8 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,

View File

@@ -46,6 +46,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -87,6 +88,7 @@ import {
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -238,6 +240,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -536,6 +542,8 @@ export const useSettingsState = () => {
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -653,7 +661,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -662,7 +670,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -776,6 +784,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -1014,6 +1029,8 @@ export const useSettingsState = () => {
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1058,7 +1075,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -64,6 +64,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -228,6 +229,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -405,6 +407,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -537,6 +541,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}

View File

@@ -397,6 +397,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<SettingsShortcutsTab
hotkeyScheme={settings.hotkeyScheme}
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
keyBindings={settings.keyBindings}
updateKeyBinding={settings.updateKeyBinding}
resetKeyBinding={settings.resetKeyBinding}

View File

@@ -1118,7 +1118,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};

View File

@@ -24,6 +24,7 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
@@ -100,6 +101,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
@@ -479,26 +482,28 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
let hostForSession: Host;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
map.set(session.id, existingHost);
hostForSession = existingHost;
} else {
map.set(session.id, {
hostForSession = {
...existingHost,
protocol,
port,
moshEnabled,
etEnabled,
});
};
}
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
} else {
// Create stable fallback host object
const fallbackProtocol = session.protocol ?? 'local' as const;
map.set(session.id, {
const fallbackHost: Host = {
id: session.hostId,
label: session.hostLabel || 'Local Terminal',
hostname: session.hostname || 'localhost',
@@ -523,7 +528,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
});
};
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
}
}
return map;
@@ -619,6 +625,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [hostMap, sessions, keys, identities]);
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
@@ -630,7 +644,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateTerminalFontSize]);
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
@@ -1083,6 +1097,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onUpdateTerminalThemeId,
pendingTerminalSelectionForAI,
refocusActiveTerminalSession,

View File

@@ -5,11 +5,13 @@ import { keyEventToString } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { SectionHeader, Select, SettingsTabContent, SettingRow } from "../settings-ui";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
export default function SettingsShortcutsTab(props: {
hotkeyScheme: HotkeyScheme;
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
keyBindings: KeyBinding[];
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
@@ -19,6 +21,8 @@ export default function SettingsShortcutsTab(props: {
const {
hotkeyScheme,
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
keyBindings,
updateKeyBinding,
resetKeyBinding,
@@ -136,6 +140,15 @@ export default function SettingsShortcutsTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
>
<Toggle
checked={shellOnlyTabNumberShortcuts}
onChange={setShellOnlyTabNumberShortcuts}
/>
</SettingRow>
</div>
{hotkeyScheme !== "disabled" && (

View File

@@ -53,7 +53,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
>
<div
className={cn(
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
"relative h-full w-full flex min-h-0 overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
style={terminalPreviewVars}
@@ -200,7 +200,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
</div>
<div
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
className="flex-1 min-h-0 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
>
<div

View File

@@ -254,3 +254,38 @@ test("startEt connects directly when no jump host is configured", async () => {
assert.ok(captured);
assert.equal(captured.jumpHosts, undefined);
});
test("startEt forwards known hosts and algorithm options for stats companion parity", async () => {
let captured: Record<string, unknown> | null = null;
const knownHosts = [{
id: "kh-1",
hostname: "target.example.test",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "SHA256:trusted",
publicKey: "",
discoveredAt: 1,
}];
const algorithms = { cipher: ["aes128-cbc"] };
const backend = makeBackend((options) => { captured = options; });
const ctx = {
...makeCtx(
{
legacyAlgorithms: true,
skipEcdsaHostKey: true,
algorithms,
},
[],
backend,
),
knownHosts,
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.ok(captured);
assert.equal(captured.knownHosts, knownHosts);
assert.equal(captured.legacyAlgorithms, true);
assert.equal(captured.skipEcdsaHostKey, true);
assert.equal(captured.algorithmOverrides, algorithms);
});

View File

@@ -997,6 +997,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
port: ctx.host.port || 22,
etPort: ctx.host.etPort,
legacyAlgorithms: ctx.host.legacyAlgorithms,
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
algorithmOverrides: ctx.host.algorithms,
knownHosts: ctx.knownHosts,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,

View File

@@ -48,7 +48,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
@@ -436,69 +436,81 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}, [effectiveTheme]);
// Keep font-size sync separate from terminalSettings so unrelated setting
// updates (or focus/layout re-renders) do not reset a wheel/Ctrl zoom that
// has not yet propagated into React props.
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.fontFamily = resolvedFontFamily;
if (!termRef.current) return;
termRef.current.options.fontSize = effectiveFontSize;
xtermRuntimeRef.current?.clearTextureAtlas();
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
}, [effectiveFontSize]);
if (terminalSettings) {
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: termRef.current?.options.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
useEffect(() => {
if (!termRef.current) return;
termRef.current.options.fontFamily = resolvedFontFamily;
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
termRef.current.options.drawBoldTextInBrightColors =
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
if (terminalSettings) {
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: termRef.current?.options.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
termRef.current.options.drawBoldTextInBrightColors =
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
@@ -640,6 +652,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
await fontFaceSet.ready;
if (cancelled) return;
// Ensure bundled Nerd Font icon fallbacks are loaded at the terminal's
// cell size. Shell prompts can arrive before these faces finish loading
// on cold start (Linux), leaving Powerline glyphs cached as tofu (#1363).
try {
await fontFaceSet.load(`${effectiveFontSize}px "Symbols Nerd Font Mono"`);
} catch (err) {
logger.warn("Nerd Font preload failed", err);
}
if (cancelled) return;
const term = termRef.current as {
cols: number;
rows: number;
@@ -652,6 +674,13 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
logger.warn("Font remeasure failed", err);
}
// remeasureFont does not invalidate cells rasterized before fonts were ready.
xtermRuntimeRef.current?.clearTextureAtlas();
const visibleTerm = termRef.current;
if (visibleTerm) {
forceSyncRenderAfterResize(visibleTerm);
}
try {
fitAddon?.fit();
} catch (err) {
@@ -776,6 +805,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return () => clearTimeout(timer);
}, [isSearchOpen]);
// When compose bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
safeFit({ force: true, requireVisible: true });
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isComposeBarOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);

View File

@@ -479,6 +479,8 @@ export interface TerminalLayerProps {
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
onClearSessionFontSizeOverride?: (sessionId: string) => void;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
onUpdateHostDistro: (hostId: string, distro: string) => void;

View File

@@ -142,6 +142,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onUpdateHost: s.onUpdateHost,
onUpdateTerminalFontFamilyId: s.onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize: s.onUpdateTerminalFontSize,
onUpdateSessionFontSize: s.onUpdateSessionFontSize,
onClearSessionFontSizeOverride: s.onClearSessionFontSizeOverride,
onUpdateTerminalFontWeight: s.onUpdateTerminalFontWeight,
onUpdateTerminalThemeId: s.onUpdateTerminalThemeId,
sessionHostsMap,
@@ -187,6 +189,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleToggleScriptsSidePanel: s.handleToggleScriptsSidePanel,
handleToggleSidePanel: s.handleToggleSidePanel,
hasNotifiableTerminalOutput: s.hasNotifiableTerminalOutput,
isComposeBarOpen: s.isComposeBarOpen,
isFocusMode,
isTerminalLayerVisible,
lastSidePanelTabRef: s.lastSidePanelTabRef,
@@ -216,6 +219,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
setTimeout,
setupMcpApprovalBridge: s.setupMcpApprovalBridge,
setWorkspaceArea,
sidePanelPosition: s.sidePanelPosition,
sidePanelWidth: s.sidePanelWidth,
sftpActiveHost,
sftpHostForTab,
shouldMarkSessionActivity: s.shouldMarkSessionActivity,

View File

@@ -11,53 +11,22 @@ type TerminalLayerViewContext = Record<string, any>;
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const {
activeWorkspace,
composeBarThemeColors,
focusedSessionId,
handleComposeSend,
handleSnippetFromPanel,
isBroadcastEnabled,
isComposeBarOpen,
isTerminalLayerVisible,
refocusTerminalSession,
setIsComposeBarOpen,
snippets,
TerminalComposeBar,
workspaceOuterRef,
} = ctx;
return (
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
ref={ctx.workspaceOuterRef}
className="absolute inset-0 bg-background flex min-h-0"
data-section="terminal-workspace"
style={{
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
zIndex: isTerminalLayerVisible ? 10 : 0,
visibility: ctx.isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: ctx.isTerminalLayerVisible ? 'auto' : 'none',
zIndex: ctx.isTerminalLayerVisible ? 10 : 0,
left: hostTreeLayoutWidth,
}}
>
<div className="flex-1 flex min-h-0 relative">
<TerminalLayerSidePanelSection ctx={ctx} />
<TerminalLayerFocusSidebarSection ctx={ctx} />
<TerminalLayerWorkspaceSection ctx={ctx} />
</div>
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
<TerminalLayerSidePanelSection ctx={ctx} />
<TerminalLayerFocusSidebarSection ctx={ctx} />
<TerminalLayerWorkspaceSection ctx={ctx} />
</div>
);
}

View File

@@ -69,14 +69,22 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
handleAddSelectionToAI,
activeResizers,
activeWorkspace,
composeBarThemeColors,
findSplitNode,
focusedSessionId,
handleComposeSend,
handleSnippetFromPanel,
refocusTerminalSession,
setIsComposeBarOpen,
setResizing,
TerminalComposeBar,
Array,
cn,
} = ctx;
return (
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
<div className="flex-1 min-h-0 flex flex-col">
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
@@ -219,6 +227,21 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
);
})}
</div>
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
);
}

View File

@@ -255,7 +255,14 @@ const WORKSPACE_CTX_KEYS = [
'handleAddSelectionToAI',
'activeResizers',
'activeWorkspace',
'composeBarThemeColors',
'findSplitNode',
'focusedSessionId',
'handleComposeSend',
'handleSnippetFromPanel',
'refocusTerminalSession',
'setIsComposeBarOpen',
'TerminalComposeBar',
'setResizing',
'Array',
'cn',

View File

@@ -1,12 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
type TerminalLayerEffectsContext = Record<string, any>;
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const sidePanelShellWidth = getTerminalSidePanelShellWidth({
activeSidePanelTab,
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL,
isSidePanelOpenForCurrentTab,
resizePreviewWidth: null,
sidePanelWidth,
});
const remeasureWorkspaceArea = useCallback(() => {
const el = workspaceInnerRef.current;
if (!el) return;
const width = el.clientWidth;
const height = el.clientHeight;
if (width <= 0 || height <= 0) return;
setWorkspaceArea((prev) => (
prev.width === width && prev.height === height
? prev
: { width, height }
));
}, [setWorkspaceArea, workspaceInnerRef]);
const scheduleWorkspaceAreaRemeasure = useCallback(() => {
remeasureWorkspaceArea();
requestAnimationFrame(() => {
remeasureWorkspaceArea();
requestAnimationFrame(remeasureWorkspaceArea);
});
}, [remeasureWorkspaceArea, requestAnimationFrame]);
useEffect(() => {
const liveSessionIds = new Set(sessions.map((session) => session.id));
@@ -94,16 +129,10 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
if (!workspaceInnerRef.current) return;
const el = workspaceInnerRef.current;
const updateSize = () => {
const width = el.clientWidth;
const height = el.clientHeight;
// Ignore zero-size reads while the layer is hidden so split rects are
// not recomputed from a 1×1 fallback until the real layout is available.
if (width <= 0 || height <= 0) return;
setWorkspaceArea((prev) => (
prev.width === width && prev.height === height
? prev
: { width, height }
));
if (!isTerminalLayerVisible) return;
remeasureWorkspaceArea();
};
updateSize();
const observer = new ResizeObserver(() => updateSize());
@@ -111,31 +140,30 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
// Re-measure when a drag ends so pane rects match the committed layout.
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
if (!terminalLayoutSuppressStore.getActive()) {
updateSize();
scheduleWorkspaceAreaRemeasure();
}
});
return () => {
unsubscribeSuppress();
observer.disconnect();
};
}, [isTerminalLayerVisible]);
}, [isTerminalLayerVisible, remeasureWorkspaceArea, scheduleWorkspaceAreaRemeasure, workspaceInnerRef]);
// Discrete layout changes (side panel toggle, compose bar, workspace tab/view mode)
// can miss a ResizeObserver tick; host-tree width is handled by the observer
// because it updates continuously during drag.
useEffect(() => {
if (!isTerminalLayerVisible || !workspaceInnerRef.current) return;
const el = workspaceInnerRef.current;
const updateSize = () => {
const width = el.clientWidth;
const height = el.clientHeight;
if (width <= 0 || height <= 0) return;
setWorkspaceArea((prev) => (
prev.width === width && prev.height === height
? prev
: { width, height }
));
};
updateSize();
requestAnimationFrame(updateSize);
}, [activeWorkspace, isTerminalLayerVisible]);
if (!isTerminalLayerVisible) return;
scheduleWorkspaceAreaRemeasure();
}, [
activeWorkspaceId,
activeWorkspaceViewMode,
isComposeBarOpen,
isTerminalLayerVisible,
scheduleWorkspaceAreaRemeasure,
sidePanelPosition,
sidePanelShellWidth,
]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.

View File

@@ -41,6 +41,8 @@ interface UseTerminalThemePanelStateOptions {
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
onClearSessionFontSizeOverride?: (sessionId: string) => void;
sessionHostsMap: Map<string, Host>;
terminalFontFamilyId: string;
terminalSettings?: { fontWeight?: number };
@@ -63,6 +65,8 @@ export function useTerminalThemePanelState({
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateTerminalThemeId,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
sessionHostsMap,
terminalFontFamilyId,
terminalSettings,
@@ -252,6 +256,10 @@ export function useTerminalThemePanelState({
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (activeWorkspace && focusedSessionId) {
onUpdateSessionFontSize?.(focusedSessionId, newFontSize);
return;
}
if (isFocusedHostEphemeral) {
onUpdateTerminalFontSize?.(newFontSize);
return;
@@ -260,12 +268,17 @@ export function useTerminalThemePanelState({
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
});
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
}, [activeWorkspace, focusedHost, focusedFontSize, focusedSessionId, isFocusedHostEphemeral, onUpdateSessionFontSize, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
if (!focusedHost) return;
if (activeWorkspace && focusedSessionId) {
onClearSessionFontSizeOverride?.(focusedSessionId);
return;
}
if (isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
}, [activeWorkspace, focusedHost, focusedSessionId, isFocusedHostEphemeral, onClearSessionFontSizeOverride, onUpdateHost, rawFocusedHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;

View File

@@ -347,4 +347,7 @@ export interface TerminalSession {
// falls back to a fresh connection — so this also applies on reconnect: a
// reconnect reuses the source again if still connected, else dials fresh.
reuseConnectionFromSessionId?: string;
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
fontSize?: number;
fontSizeOverride?: boolean;
}

View File

@@ -249,6 +249,8 @@ export interface SyncPayload {
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
// Terminal/editor tabs: show left host list sidebar
showHostTreeSidebar?: boolean;
// Workspace focus indicator style

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
applyCustomAccentToTerminalTheme,
applySessionFontSizeToHost,
mergeTerminalHostUpdate,
resolveFollowedTerminalThemeId,
TERMINAL_THEME_AUTO,
@@ -128,6 +129,34 @@ test("terminal updates still persist SFTP bookmarks", () => {
]);
});
test("applySessionFontSizeToHost overlays workspace pane font size", () => {
const host: Host = {
id: "host-1",
label: "Server",
hostname: "example.com",
username: "root",
port: 22,
group: "",
tags: [],
};
const session = {
id: "session-1",
hostId: "host-1",
hostLabel: "Server",
username: "root",
hostname: "example.com",
status: "connected" as const,
workspaceId: "workspace-1",
fontSize: 18,
fontSizeOverride: true,
};
const merged = applySessionFontSizeToHost(host, session);
assert.equal(merged.fontSize, 18);
assert.equal(merged.fontSizeOverride, true);
});
test("terminal appearance reset clears only appearance fields", () => {
const hostWithAppearance: Host = {
...savedHost,

View File

@@ -1,4 +1,4 @@
import { Host, TerminalTheme } from './models';
import { Host, TerminalSession, TerminalTheme } from './models';
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -216,6 +216,23 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
export const hasSessionFontSizeOverride = (
session?: Pick<TerminalSession, 'fontSizeOverride' | 'fontSize'> | null,
): boolean => hasHostFontSizeOverride(session);
export const applySessionFontSizeToHost = (host: Host, session?: TerminalSession): Host => {
if (!session || !hasSessionFontSizeOverride(session) || session.fontSize == null) {
return host;
}
return { ...host, fontSize: session.fontSize, fontSizeOverride: true };
};
export const clearSessionFontSizeOverride = (session: TerminalSession): TerminalSession => ({
...session,
fontSize: undefined,
fontSizeOverride: false,
});
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));

View File

@@ -990,7 +990,7 @@ const { isHostKeyTrustedBySystem } = createSystemKnownHostsApi({
});
const { createMoshStatsConnectionApi } = require("./sshBridge/moshStatsConnection.cjs");
const { ensureMoshStatsConnection } = createMoshStatsConnectionApi({
const { ensureMoshStatsConnection, ensureEtStatsConnection } = createMoshStatsConnectionApi({
get sessions() { return sessions; },
SSHClient, sshUtils, NetcattyAgent, buildAlgorithms, getSshAgentSocket,
readFileNoFollow, expandIdentityFilePath, isAutoFillablePasswordChallenge,
@@ -1003,7 +1003,7 @@ const sessionOpsApi = createSessionOpsApi({
get electronModule() { return electronModule; },
fs, path, os, exec, randomUUID, iconv, Buffer, process, console, setTimeout, clearTimeout,
getSessionDecoder, resetSessionDecoders, sessionEncodings, resolveLangFromCharset, safeSend,
quoteShellArg, log, ensureMoshStatsConnection,
quoteShellArg, log, ensureMoshStatsConnection, ensureEtStatsConnection,
execOnEtSession: (...args) => require("./terminalBridge.cjs").execOnEtSession(...args),
getServerStats: undefined,
});

View File

@@ -1,9 +1,31 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createSessionOpsApi } = require("./sshBridge/sessionOps.cjs");
function makeApi(session, execOnEtSession) {
function fakeStream(stdout) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
setImmediate(() => {
if (stdout) stream.emit("data", Buffer.from(stdout));
stream.emit("close", 0);
});
return stream;
}
function fakeConn(stdout) {
return {
exec(_command, cb) {
cb(null, fakeStream(stdout));
},
};
}
const LINUX_STATS =
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:";
function makeApi(session, execOnEtSession, extra = {}) {
const sessions = new Map([["et-1", session]]);
return createSessionOpsApi({
sessions,
@@ -14,6 +36,7 @@ function makeApi(session, execOnEtSession) {
iconv: { encodingExists: () => true },
sessionEncodings: new Map(),
resetSessionDecoders: () => {},
...extra,
});
}
@@ -34,14 +57,99 @@ test("getSessionDistroInfo probes ET sessions through execOnEtSession", async ()
assert.match(command, /os-release/);
});
test("getServerStats explicitly skips ET sessions instead of treating them as missing", async () => {
test("getServerStats opens an ET stats companion connection for direct ET sessions", async () => {
const session = {
type: "et",
sshUserHost: "alice@example.test",
sshOptions: [],
sshEnv: {},
etStatsAuth: { hostname: "example.test", username: "alice" },
};
let ensureCalls = 0;
let execFallbackCalls = 0;
const api = makeApi(
{ type: "et", sshUserHost: "alice@example.test", sshOptions: [], sshEnv: {} },
async () => ({ success: true, stdout: "", stderr: "" }),
session,
async () => {
execFallbackCalls += 1;
return { success: true, stdout: LINUX_STATS, stderr: "" };
},
{
ensureEtStatsConnection: async (s, id) => {
ensureCalls += 1;
assert.equal(s, session);
assert.equal(id, "et-1");
s.etStatsConn = fakeConn(LINUX_STATS);
return s.etStatsConn;
},
},
);
const result = await api.getServerStats(null, { sessionId: "et-1" });
assert.equal(result.success, false);
assert.match(result.error, /not supported for EternalTerminal/i);
assert.equal(ensureCalls, 1);
assert.equal(execFallbackCalls, 0);
assert.equal(session.conn, undefined);
assert.equal(result.success, true);
assert.equal(result.stats.memTotal, 8000);
assert.equal(result.stats.cpuCores, 4);
});
test("getServerStats falls back to execOnEtSession for jumped ET sessions", async () => {
let command = "";
let ensureCalls = 0;
const api = makeApi(
{
type: "et",
sshUserHost: "alice@example.test",
sshOptions: [],
sshEnv: {},
etStatsAuth: { hostname: "example.test", hasJumpHost: true },
},
async (_session, cmd) => {
command = cmd;
return { success: true, stdout: LINUX_STATS, stderr: "" };
},
{
ensureEtStatsConnection: async () => {
ensureCalls += 1;
return null;
},
},
);
const result = await api.getServerStats(null, { sessionId: "et-1" });
assert.equal(ensureCalls, 0);
assert.match(command, /CPURAW|UNSUPPORTED_OS/);
assert.equal(result.success, true);
assert.equal(result.stats.memTotal, 8000);
});
test("getServerStats falls back to execOnEtSession when the direct ET companion is unavailable", async () => {
let execFallbackCalls = 0;
const api = makeApi(
{
type: "et",
sshUserHost: "alice@example.test",
sshOptions: [],
sshEnv: {},
etStatsAuth: { hostname: "example.test" },
etStatsConnFailed: true,
},
async () => {
execFallbackCalls += 1;
return { success: true, stdout: LINUX_STATS, stderr: "" };
},
{
ensureEtStatsConnection: async () => {
throw new Error("should not retry failed companion");
},
},
);
const result = await api.getServerStats(null, { sessionId: "et-1" });
assert.equal(execFallbackCalls, 1);
assert.equal(result.success, true);
assert.equal(result.stats.memTotal, 8000);
});

View File

@@ -116,7 +116,7 @@ function createMoshStatsConnectionApi(ctx) {
// keys. `trust.trusted` additionally gates the password method in the
// authHandler (defense in depth); `trust.rejected` lets the caller treat an
// untrusted host as a permanent failure so it stops reconnecting every poll.
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, trust }) {
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, trust, label }) {
return (rawKey, callback) => {
try {
const keyInfo = hostKeyVerifier.describeHostKey(rawKey);
@@ -140,7 +140,7 @@ function createMoshStatsConnectionApi(ctx) {
}) === true;
}
} catch (err) {
log("[Mosh] stats companion host-key check failed:", err?.message || String(err));
log(`[${label}] stats companion host-key check failed:`, err?.message || String(err));
trust.trusted = false;
}
if (!trust.trusted) trust.rejected = true;
@@ -178,7 +178,7 @@ function createMoshStatsConnectionApi(ctx) {
};
}
async function buildStatsConnectOpts(auth) {
async function buildStatsConnectOpts(auth, label = "Mosh") {
const connectOpts = {
host: auth.hostname,
port: auth.port || 22,
@@ -268,6 +268,7 @@ function createMoshStatsConnectionApi(ctx) {
port: connectOpts.port,
knownHosts: auth.knownHosts,
trust,
label,
});
// When a plaintext password is in play, also gate it behind the trust
@@ -310,23 +311,43 @@ function createMoshStatsConnectionApi(ctx) {
* @param {Electron.WebContents} [webContents] - sender of the stats IPC,
* used only for certificate-agent construction.
*/
function ensureMoshStatsConnection(session, sessionId, webContents) {
function ensureStatsConnection(session, sessionId, webContents, opts) {
if (!session) return Promise.resolve(null);
// A previously established companion is reused.
if (session.moshStatsConn) return Promise.resolve(session.moshStatsConn);
if (session[opts.connProp]) return Promise.resolve(session[opts.connProp]);
// A prior attempt permanently failed — don't keep retrying every poll.
if (session.moshStatsConnFailed) return Promise.resolve(null);
if (session[opts.failedProp]) return Promise.resolve(null);
// Reuse an in-flight attempt so two near-simultaneous polls don't open
// two connections.
if (session.moshStatsConnPromise) return session.moshStatsConnPromise;
if (session[opts.promiseProp]) return session[opts.promiseProp];
const promise = establishMoshStatsConnection(session, sessionId, webContents).finally(() => {
session.moshStatsConnPromise = null;
const promise = establishStatsConnection(session, sessionId, webContents, opts).finally(() => {
session[opts.promiseProp] = null;
});
session.moshStatsConnPromise = promise;
session[opts.promiseProp] = promise;
return promise;
}
function ensureMoshStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "Mosh",
authProp: "moshStatsAuth",
connProp: "moshStatsConn",
failedProp: "moshStatsConnFailed",
promiseProp: "moshStatsConnPromise",
});
}
function ensureEtStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "ET",
authProp: "etStatsAuth",
connProp: "etStatsConn",
failedProp: "etStatsConnFailed",
promiseProp: "etStatsConnPromise",
});
}
// True once the session has gone away — either explicitly closed or
// dropped from the shared map (e.g. the mosh-client PTY exited while we
// were still connecting the companion).
@@ -334,8 +355,8 @@ function createMoshStatsConnectionApi(ctx) {
return session.closed || sessions.get(sessionId) !== session;
}
async function establishMoshStatsConnection(session, sessionId, webContents) {
const auth = session.moshStatsAuth;
async function establishStatsConnection(session, sessionId, webContents, opts) {
const auth = session[opts.authProp];
if (!auth || !auth.hostname) {
// moshStatsAuth is only assigned once the handshake completes and the
// session swaps to mosh-client. The renderer can mark a session
@@ -349,11 +370,11 @@ function createMoshStatsConnectionApi(ctx) {
const { connectOpts, hasAnyAuth, trust } = await buildStatsConnectOpts({
...auth,
webContents,
});
}, opts.label);
if (!hasAnyAuth) {
// Nothing we can authenticate with non-interactively (e.g. the user
// typed a password into the Mosh handshake PTY that we never stored).
session.moshStatsConnFailed = true;
session[opts.failedProp] = true;
return null;
}
@@ -399,7 +420,7 @@ function createMoshStatsConnectionApi(ctx) {
// retry.
const fail = (err, permanent) => {
try { conn.end(); } catch { /* ignore */ }
if (permanent) session.moshStatsConnFailed = true;
if (permanent) session[opts.failedProp] = true;
finish(null);
};
@@ -410,17 +431,18 @@ function createMoshStatsConnectionApi(ctx) {
finish(null);
return;
}
// Stored only on moshStatsConn — never on session.conn (see the
// ensureMoshStatsConnection docstring for why).
session.moshStatsConn = conn;
// Stored only on the protocol-specific companion property
// (opts.connProp, e.g. moshStatsConn / etStatsConn) — never on
// session.conn (see ensureMoshStatsConnection docstring for why).
session[opts.connProp] = conn;
finish(conn);
});
conn.on("error", (err) => {
log("[Mosh] stats companion connection error:", err?.message || String(err));
log(`[${opts.label}] stats companion connection error:`, err?.message || String(err));
// If this fired after we already adopted the connection, drop the
// stale handle so the next poll can rebuild a fresh one.
if (session.moshStatsConn === conn) session.moshStatsConn = null;
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// Auth rejection won't change with the same stored credentials, and a
// host-key rejection (untrusted host) won't either until the user
// vets the host via a real session — treat both as permanent so we
@@ -429,10 +451,11 @@ function createMoshStatsConnectionApi(ctx) {
});
conn.on("close", () => {
if (session.moshStatsConn === conn) session.moshStatsConn = null;
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// If the socket closed mid-handshake without ever emitting "ready"
// or "error", settle the attempt here so the awaiting getServerStats
// call (and session.moshStatsConnPromise) don't hang forever. This
// call (and the in-flight promise on opts.promiseProp) don't hang
// forever. This
// is treated as transient — the next poll may retry.
finish(null);
});
@@ -440,7 +463,7 @@ function createMoshStatsConnectionApi(ctx) {
try {
conn.connect(connectOpts);
} catch (err) {
log("[Mosh] stats companion connect threw:", err?.message || String(err));
log(`[${opts.label}] stats companion connect threw:`, err?.message || String(err));
// A synchronous throw from connect() (e.g. malformed options) won't
// succeed on retry either.
fail(err, true);
@@ -448,7 +471,7 @@ function createMoshStatsConnectionApi(ctx) {
});
}
return { ensureMoshStatsConnection };
return { ensureMoshStatsConnection, ensureEtStatsConnection };
}
}

View File

@@ -147,6 +147,47 @@ test("connects with a stored password and adopts the connection on ready", async
assert.equal(session.conn, undefined);
});
test("ET stats companion uses ET-specific session fields", async () => {
const { api, sessions } = makeApi();
const session = { etStatsAuth: { hostname: "example.com", port: 2222, username: "alice", password: "secret" } };
sessions.set("sid", session);
const pending = api.ensureEtStatsConnection(session, "sid");
await tick();
assert.equal(FakeSSHClient.instances.length, 1);
const client = FakeSSHClient.instances[0];
assert.equal(client.connectOpts.host, "example.com");
assert.equal(client.connectOpts.port, 2222);
assert.equal(client.connectOpts.username, "alice");
assert.equal(client.connectOpts.password, "secret");
client.emitReady();
const result = await pending;
assert.equal(result, client);
assert.equal(session.etStatsConn, client);
assert.equal(session.moshStatsConn, undefined);
assert.equal(session.conn, undefined);
});
test("ET stats companion caches permanent failures on ET-specific fields", async () => {
const { api, sessions } = makeApi();
const session = { etStatsAuth: { hostname: "h", username: "u", password: "p" } };
sessions.set("sid", session);
const p1 = api.ensureEtStatsConnection(session, "sid");
await tick();
const authErr = new Error("All configured authentication methods failed");
authErr.level = "client-authentication";
FakeSSHClient.instances[0].emitError(authErr);
assert.equal(await p1, null);
assert.equal(session.etStatsConnFailed, true);
const r2 = await api.ensureEtStatsConnection(session, "sid");
assert.equal(r2, null);
assert.equal(FakeSSHClient.instances.length, 1);
});
test("uses a parseable private key and passphrase, not password fallback only", async () => {
const { api, sessions } = makeApi();
const session = {

View File

@@ -33,7 +33,10 @@ function createSessionOpsApi(ctx) {
if (typeof execOnEtSession !== "function") {
return { success: false, error: "ET command executor unavailable" };
}
return execOnEtSession(session, "cat /etc/os-release 2>/dev/null || uname -a", 5000);
return execOnEtSession(session, "cat /etc/os-release 2>/dev/null || uname -a", 5000, {
requireTrustedHost: true,
knownHosts: session.etStatsAuth?.knownHosts,
});
}
if (!session || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
@@ -497,23 +500,82 @@ function createSessionOpsApi(ctx) {
return { success: false, error: 'Session not found or not connected' };
}
if (session.type === "et") {
return { success: false, error: "Server stats are not supported for EternalTerminal sessions" };
const isEtSession = session.type === "et";
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
function createBufferedExecStream(stdout = "", stderr = "") {
const stream = {
stderr: {
on(eventName, handler) {
if (eventName === "data" && stderr) {
setTimeout(() => handler(Buffer.from(stderr)), 0);
}
return this;
},
},
on(eventName, handler) {
if (eventName === "data" && stdout) {
setTimeout(() => handler(Buffer.from(stdout)), 0);
}
if (eventName === "close") {
setTimeout(() => handler(0), 0);
}
return this;
},
close() {},
destroy() {},
};
return stream;
}
// Mosh sessions run over UDP via a local mosh-client PTY and have no
function createEtStatsExecConn(etSession) {
return {
exec(command, cb) {
execOnEtSession(etSession, command, 4500, {
requireTrustedHost: true,
knownHosts: etSession.etStatsAuth?.knownHosts,
})
.then((result) => {
if (!result?.success) {
cb(new Error(result?.error || result?.stderr || "ET stats command failed"));
return;
}
cb(null, createBufferedExecStream(result.stdout || "", result.stderr || ""));
})
.catch((err) => {
cb(err instanceof Error ? err : new Error(String(err)));
});
},
};
}
// Mosh and direct ET sessions run through external PTYs and have no
// ssh2 connection of their own. Lazily open a best-effort companion SSH
// connection (reusing the handshake credentials) so the host-info bar
// works for Mosh too (issue #1198). The companion lives on
// session.moshStatsConn — deliberately NOT session.conn — so it stays
// invisible to other bridges (getSessionPwd / SFTP / MCP exec) that key
// off session.conn as the interactive connection. This is a no-op for
// real SSH sessions, which already carry session.conn.
if (!session.conn && !session.moshStatsConn && typeof ensureMoshStatsConnection === 'function') {
await ensureMoshStatsConnection(session, sessionId, event?.sender);
// connection (reusing credentials) so the host-info bar can use the same
// stats parser as normal SSH. The companion lives on protocol-specific
// fields — never session.conn — so other bridges do not mistake it for
// the interactive connection. ET sessions with a jump host use the
// already-prepared OpenSSH environment via execOnEtSession instead.
if (!session.conn && !session.moshStatsConn && !session.etStatsConn) {
if (session.type === 'mosh' && typeof ensureMoshStatsConnection === 'function') {
await ensureMoshStatsConnection(session, sessionId, event?.sender);
} else if (
isEtSession &&
!etUsesExecFallback &&
!session.etStatsConnFailed &&
typeof ensureEtStatsConnection === 'function'
) {
await ensureEtStatsConnection(session, sessionId, event?.sender);
}
}
const conn = session.conn || session.moshStatsConn;
const canExecEtStats =
isEtSession &&
typeof execOnEtSession === "function" &&
!!session.sshUserHost &&
!session.externalAuthArtifactsCleaned;
const conn = session.conn || session.moshStatsConn || session.etStatsConn ||
(canExecEtStats ? createEtStatsExecConn(session) : null);
if (!conn) {
// A Mosh session can be marked "connected" (and start polling) from
// the SSH bootstrap's visible output before swapToMoshClient stores

View File

@@ -292,6 +292,7 @@ function createSystemKnownHostsApi(ctx) {
return {
isHostKeyTrustedBySystem,
readSystemKnownHostsContent,
// Exposed for unit testing.
parseKnownHostsLine,
hostFieldMatches,

View File

@@ -835,9 +835,10 @@ function closeSession(event, payload) {
} else if (session.proc) {
session.proc.kill();
// Mosh sessions may also carry a companion ssh2 connection opened
// lazily for host-info stats (issue #1198), stored on moshStatsConn so
// it stays separate from session.conn. Close it here to avoid leaking it.
session.moshStatsConn?.end();
// lazily for host-info stats (issue #1198). ET can use the same pattern.
// Close companions here to avoid leaking them.
try { session.moshStatsConn?.end(); } catch { /* ignore */ }
try { session.etStatsConn?.end(); } catch { /* ignore */ }
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
@@ -1014,8 +1015,9 @@ function cleanupAllSessions() {
// Ignore errors during cleanup
}
// Tear down a Mosh stats companion ssh2 connection if one was opened
// (issue #1198) — it lives on moshStatsConn, separate from session.conn.
// (issue #1198), and the equivalent ET companion when present.
try { session.moshStatsConn?.end(); } catch (e) { /* ignore */ }
try { session.etStatsConn?.end(); } catch (e) { /* ignore */ }
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {

View File

@@ -1,4 +1,7 @@
/* eslint-disable no-undef */
const crypto = require("node:crypto");
const { createSystemKnownHostsApi } = require("../sshBridge/systemKnownHosts.cjs");
//
// EternalTerminal session backend, factored into the createXxxSessionApi
// pattern used by moshSession.cjs / telnetSession.cjs. Dependencies arrive
@@ -533,21 +536,121 @@ main();
return env;
}
function formatVaultKnownHostLine(knownHost) {
if (!knownHost?.hostname || !knownHost?.keyType) return null;
const port = Number.isFinite(knownHost.port) ? Number(knownHost.port) : 22;
const hostField = port !== 22 ? `[${knownHost.hostname}]:${port}` : knownHost.hostname;
const pubKey = String(knownHost.publicKey || "").trim();
const parts = pubKey.split(/\s+/);
let keyType = knownHost.keyType;
let keyBlob = "";
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
keyType = parts[0];
keyBlob = parts[1];
} else if (parts.length === 1 && parts[0].length > 0 && !/^SHA256:/i.test(parts[0])) {
keyBlob = parts[0];
} else {
return null;
}
if (!keyBlob) return null;
return `${hostField} ${keyType} ${keyBlob}`;
}
/**
* Build a known_hosts file for background ET exec (stats / distro probes).
* Merges the user's system known_hosts with any Netcatty-vault entries that
* carry a full public key blob, then pins StrictHostKeyChecking=yes on exec
* so accept-new cannot auto-trust a host in a non-interactive flow.
*/
function ensureStrictExecKnownHostsFile(session, knownHosts) {
if (session.etStrictExecKnownHostsPath) {
return session.etStrictExecKnownHostsPath;
}
const { readSystemKnownHostsContent } = createSystemKnownHostsApi({
fs, path, os, crypto, log: console,
});
const chunks = [];
try {
const systemContent = readSystemKnownHostsContent();
if (systemContent) chunks.push(systemContent);
} catch {
// ignore read failures — strict checking fails closed below
}
const configuredKnownHosts = (session.sshOptions || []).find(
(opt) => opt.startsWith("UserKnownHostsFile="),
);
if (configuredKnownHosts) {
const configuredPath = configuredKnownHosts.slice("UserKnownHostsFile=".length);
try {
const configuredContent = fs.readFileSync(configuredPath, "utf8");
if (configuredContent) chunks.push(configuredContent);
} catch {
// ignore missing configured file
}
}
const vaultLines = [];
if (Array.isArray(knownHosts)) {
for (const knownHost of knownHosts) {
const line = formatVaultKnownHostLine(knownHost);
if (line) vaultLines.push(line);
}
}
if (vaultLines.length > 0) {
chunks.push(vaultLines.join("\n"));
}
const artifact = Array.isArray(session.externalAuthArtifacts)
? session.externalAuthArtifacts[0]
: null;
const sshDir = artifact ? path.dirname(artifact) : tempDirBridge.getTempDir();
const strictKhPath = path.join(sshDir, "netcatty-et-strict-known_hosts");
writeSecureFile(strictKhPath, chunks.filter(Boolean).join("\n") + (chunks.length ? "\n" : ""), 0o600);
session.etStrictExecKnownHostsPath = strictKhPath;
if (Array.isArray(session.externalAuthArtifacts)) {
session.externalAuthArtifacts.push(strictKhPath);
}
return strictKhPath;
}
/**
* Execute a remote command on an ET session by spawning a system ssh
* process. Reuses the SSH environment (keys, config, askpass) already
* prepared by prepareEtSshEnvironment() for the ET connection.
*
* @param {object} [execOpts]
* @param {boolean} [execOpts.requireTrustedHost] When true, refuse unknown
* host keys (StrictHostKeyChecking=yes) using system + vault known_hosts
* instead of accept-new. Used for background stats/distro probes.
* @param {Array} [execOpts.knownHosts] Netcatty vault known hosts to merge
* into the strict known_hosts file (defaults to session.etStatsAuth).
*/
function execOnEtSession(session, command, timeoutMs = 5000) {
function execOnEtSession(session, command, timeoutMs = 5000, execOpts = {}) {
if (!session?.sshUserHost || session.externalAuthArtifactsCleaned) {
return Promise.resolve({ success: false, error: "ET SSH environment not available" });
}
const requireTrustedHost = execOpts.requireTrustedHost === true;
const knownHosts = execOpts.knownHosts ?? session.etStatsAuth?.knownHosts;
const sshCmd = process.platform === "win32" ? findExecutable("ssh") : "ssh";
const args = ["-o", "BatchMode=no", "-o", "StrictHostKeyChecking=accept-new"];
const args = ["-o", "BatchMode=no"];
if (!requireTrustedHost) {
args.push("-o", "StrictHostKeyChecking=accept-new");
}
for (const opt of session.sshOptions) {
if (requireTrustedHost && opt.startsWith("StrictHostKeyChecking=")) continue;
if (requireTrustedHost && opt.startsWith("UserKnownHostsFile=")) continue;
args.push("-o", opt);
}
if (requireTrustedHost) {
const strictKhPath = ensureStrictExecKnownHostsFile(session, knownHosts);
args.push("-o", `UserKnownHostsFile=${normalizeSshConfigPath(strictKhPath)}`);
args.push("-o", "StrictHostKeyChecking=yes");
}
args.push(session.sshUserHost, command);
return new Promise((resolve) => {
@@ -666,6 +769,22 @@ main();
sshEnv: sshEnvironment?.env || {},
sshOptions: sshEnvironment?.sshOptions || [],
sshUserHost: sshEnvironment?.userHost || "",
etStatsAuth: {
hostname: options.hostname,
port: options.port || 22,
username: options.username,
password: options.password,
privateKey: options.privateKey,
passphrase: options.passphrase,
certificate: options.certificate,
keyId: options.keyId,
identityFilePaths: options.identityFilePaths,
legacyAlgorithms: options.legacyAlgorithms,
skipEcdsaHostKey: options.skipEcdsaHostKey,
algorithmOverrides: options.algorithmOverrides,
knownHosts: options.knownHosts,
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
},
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
@@ -725,6 +844,7 @@ main();
proc.onExit((evt) => {
flushEt();
try { session.etStatsConn?.end(); } catch { /* ignore */ }
cleanupSessionExternalAuthArtifacts(session);
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);

View File

@@ -9,7 +9,7 @@ const { createEtSessionApi } = require("./etSession.cjs");
// Build an et session API wired to a hermetic temp HOME so prepareEtSshEnvironment
// is deterministic regardless of the developer's real ~/.ssh contents.
function makeApi(t) {
function makeApi(t, overrides = {}) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-et-prep-"));
const fakeHome = path.join(base, "home");
fs.mkdirSync(fakeHome, { recursive: true });
@@ -36,6 +36,7 @@ function makeApi(t) {
// no-op execFileSync so writeSecureFile's Windows icacls hardening doesn't spawn
execFileSync: () => {},
execFile: () => {},
...overrides,
StringDecoder: require("node:string_decoder").StringDecoder,
randomUUID: require("node:crypto").randomUUID,
pty: {},
@@ -324,6 +325,41 @@ test("prepareEtSshEnvironment uses a persistent user known_hosts file", (t) => {
assert.equal(fs.existsSync(path.join(base, "et-ssh-home-sess1", ".ssh", "known_hosts")), false);
});
test("execOnEtSession requireTrustedHost uses strict host-key checking", async (t) => {
let capturedArgs = null;
const { api } = makeApi(t, {
execFile: (_cmd, args, _opts, cb) => {
capturedArgs = args;
process.nextTick(() => cb(null, "", ""));
},
});
const env = api.prepareEtSshEnvironment("sess1", { hostname: "host.example", username: "alice" });
const session = {
sshUserHost: env.userHost,
sshOptions: env.sshOptions,
sshEnv: env.env,
externalAuthArtifacts: env.artifacts,
externalAuthArtifactsCleaned: false,
etStatsAuth: {
knownHosts: [{
hostname: "host.example",
port: 22,
keyType: "ssh-ed25519",
publicKey: "vaultblob",
}],
},
};
await api.execOnEtSession(session, "echo ok", 1000, { requireTrustedHost: true });
const joined = capturedArgs.join(" ");
assert.match(joined, /StrictHostKeyChecking=yes/);
assert.doesNotMatch(joined, /StrictHostKeyChecking=accept-new/);
assert.ok(session.etStrictExecKnownHostsPath);
const strictContent = fs.readFileSync(session.etStrictExecKnownHostsPath, "utf8");
assert.match(strictContent, /host\.example ssh-ed25519 vaultblob/);
});
test("cleanupStaleEtTempDirs only removes Netcatty ET temp directories by prefix", (t) => {
const { api, base } = makeApi(t);
const staleEtDir = path.join(base, "et-ssh-home-old-session");

View File

@@ -160,6 +160,9 @@ export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
export const STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR = 'netcatty_show_host_tree_sidebar_v1';
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
export const STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = 'netcatty_shell_only_tab_number_shortcuts_v1';
// Group Configurations (default settings inherited by hosts)
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';

View File

@@ -58,6 +58,9 @@ declare global {
port?: number;
etPort?: number;
legacyAlgorithms?: boolean;
skipEcdsaHostKey?: boolean;
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
knownHosts?: import("../../domain/models").KnownHost[];
jumpHosts?: NetcattyJumpHost[];
agentForwarding?: boolean;
cols?: number;