Compare commits

...

7 Commits

Author SHA1 Message Date
陈大猫
8a876fd67d Merge pull request #1372 from binaricat/fix/settings-remove-lazy-tabs
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(settings): eager-load AI and sync tabs
2026-06-10 15:45:12 +08:00
bincxz
d39cd60863 fix(settings): eager-load AI and sync tabs
Remove React.lazy/Suspense for settings AI and sync tabs to avoid loading flashes when switching tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:44:34 +08:00
陈大猫
f413035295 fix(terminal): refocus input when switching work tabs on macOS (#1371)
Restore xterm keyboard focus after top-level tab changes so macOS users
can type immediately without an extra click (discussion #1339).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:27:14 +08:00
陈大猫
bfd3fb4dad feat(terminal): enhance compose bar with quick snippets and resizable input (#1370)
Add a persistent quick-snippet strip, draggable height, and terminal-matched UI to the compose bar, addressing quick-command and resize requests from community discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:18:37 +08:00
陈大猫
733e19a6f6 perf(settings): reduce Mac settings window input lag (#1347) (#1368)
* perf(settings): reduce Mac settings window input lag (#1347)

Debounce custom CSS commits, memoize heavy tabs, and replace Radix ScrollArea
with native scrolling so typing and navigation stay responsive on macOS.

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

* fix(settings): flush debounced textarea on unmount

Avoid losing custom CSS edits when the settings window closes before the
debounce timer fires.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:45:33 +08:00
陈大猫
85b552e1a6 fix(terminal): fix black block glyphs on Linux local terminal (#1364) (#1369)
* fix(terminal): resolve bold font weight without document.fonts.check false positives

Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes #1364).

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

* chore: drop unused primaryFontFamily from terminal effects context

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:39:36 +08:00
陈大猫
068730c53c fix(ui): improve host tree inline group rename interactions (#1367)
* fix(ui): improve host tree inline group rename interactions

Cancel rename when clicking another tree row and prevent parent drag from blocking text selection in the rename input.

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

* fix(ui): block group toggle keyboard while inline renaming

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

* fix(ui): cancel host inline rename when clicking other tree rows

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:38:33 +08:00
33 changed files with 1403 additions and 252 deletions

View File

@@ -21,6 +21,14 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
'terminal.composeBar.pinnedCount': '{count} pinned',
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.encoding': 'Terminal Encoding',

View File

@@ -42,6 +42,14 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.encoding': 'Кодировка терминала',

View File

@@ -229,6 +229,14 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.encoding': '终端编码',

View File

@@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
function clampHeight(height: number): number {
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
}
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
export function useComposeBarHeight() {
const [height, setHeight, persist] = useStoredNumber(
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
COMPOSE_BAR_HEIGHT_DEFAULT,
HEIGHT_CLAMP,
);
const setClampedHeight = useCallback(
(next: number | ((prev: number) => number)) => {
setHeight((prev) => {
const raw = typeof next === 'function' ? next(prev) : next;
return clampHeight(raw);
});
},
[setHeight],
);
return [height, setClampedHeight, persist] as const;
}

View File

@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
interface PinnedState {
pinnedIds: string[];
/** True when the user has never saved pins (localStorage key absent). */
neverSaved: boolean;
}
function readPinnedState(): PinnedState {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
if (stored === null) {
return { pinnedIds: [], neverSaved: true };
}
return {
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
neverSaved: false,
};
}
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
if (!snippetIdKey) return null;
const ids = snippetIdKey.split('\0').filter(Boolean);
if (ids.length === 0) return null;
return new Set(ids);
}
/**
* Persisted snippet IDs shown on the terminal compose bar quick strip.
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
* On first run, `defaultSeedIds` are written once when pins were never saved.
*/
export function useComposeBarPinnedSnippets(
snippetIdKey?: string,
defaultSeedIds?: readonly string[],
) {
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
const skipNextPersistRef = useRef(true);
const needsSeedRef = useRef(neverSaved);
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
setPinnedState((prev) => {
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
return { pinnedIds: nextIds, neverSaved: false };
});
}, []);
useEffect(() => {
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
return;
}
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
}, [pinnedIds]);
useEffect(() => {
if (!needsSeedRef.current) return;
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
if (seed.length === 0) return;
const applySeed = () => {
if (!needsSeedRef.current) return;
needsSeedRef.current = false;
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
};
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
if (!isBuiltinSeed) {
applySeed();
return;
}
// Brief delay so vault snippets can load before falling back to built-ins.
const timer = setTimeout(applySeed, 300);
return () => clearTimeout(timer);
}, [defaultSeedIds]);
useEffect(() => {
const valid = parseSnippetIdKey(snippetIdKey);
if (!valid) return;
setPinnedIds((prev) => {
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
return next.length === prev.length ? prev : next;
});
}, [snippetIdKey, setPinnedIds]);
const pin = useCallback((id: string) => {
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}, [setPinnedIds]);
const unpin = useCallback((id: string) => {
setPinnedIds((prev) => prev.filter((x) => x !== id));
}, [setPinnedIds]);
const toggle = useCallback((id: string) => {
setPinnedIds((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
));
}, [setPinnedIds]);
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
return { pinnedIds, pin, unpin, toggle, isPinned };
}

View File

@@ -108,6 +108,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { applyCustomCssToDocument } from '../../lib/customCss';
export const useSettingsState = () => {
const initialCustomKeyBindingsRecord =
@@ -777,14 +778,7 @@ export const useSettingsState = () => {
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'netcatty-custom-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
applyCustomCssToDocument(customCSS);
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;

View File

@@ -1,5 +1,5 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
hostTreeInlineGroupEditStore,
@@ -171,7 +171,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<Collapsible
open={isExpanded}
onOpenChange={() => {
if (isInlineEditing) return;
onToggle(node.path);
}}
>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
@@ -182,8 +188,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
data-section="host-tree-row"
data-row-type="group"
data-group-path={node.path}
draggable={!isInlineEditing}
onDragStart={(e) => {
if (isInlineEditing) return;
e.dataTransfer.setData("group-path", node.path);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -394,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
data-section="host-tree-row"
data-row-type="host"
data-host-id={host.id}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => {
@@ -496,6 +511,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupConfigs = [],
}) => {
const { t } = useI18n();
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!inlineEdit?.groupPath || !cancelRename) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-inline-group-edit="true"]')) return;
const row = target.closest('[data-section="host-tree-row"]');
if (!row) return;
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
cancelRename();
}, [cancelRename, inlineEdit?.groupPath]);
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -567,7 +596,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">

View File

@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsAITab from "./settings/tabs/SettingsAITab";
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
type TerminalTabSettingsProps = Pick<
SettingsState,
| 'terminalThemeId'
| 'setTerminalThemeId'
| 'followAppTerminalTheme'
| 'setFollowAppTerminalTheme'
| 'terminalThemeDarkId'
| 'setTerminalThemeDarkId'
| 'terminalThemeLightId'
| 'setTerminalThemeLightId'
| 'lightUiThemeId'
| 'darkUiThemeId'
| 'terminalFontFamilyId'
| 'setTerminalFontFamilyId'
| 'terminalFontSize'
| 'setTerminalFontSize'
| 'terminalSettings'
| 'updateTerminalSetting'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
terminalThemeId={terminalThemeId}
setTerminalThemeId={setTerminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
terminalThemeDarkId={terminalThemeDarkId}
setTerminalThemeDarkId={setTerminalThemeDarkId}
terminalThemeLightId={terminalThemeLightId}
setTerminalThemeLightId={setTerminalThemeLightId}
lightUiThemeId={lightUiThemeId}
darkUiThemeId={darkUiThemeId}
terminalFontFamilyId={terminalFontFamilyId}
setTerminalFontFamilyId={setTerminalFontFamilyId}
terminalFontSize={terminalFontSize}
setTerminalFontSize={setTerminalFontSize}
terminalSettings={terminalSettings}
updateTerminalSetting={updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
/>
);
};
});
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</AITabErrorBoundary>
);
};
@@ -333,7 +371,26 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTabContainer settings={settings} />
<SettingsTerminalTabContainer
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
)}
{mountedTabs.has("shortcuts") && (
@@ -357,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
)}
{mountedTabs.has("system") && (

View File

@@ -50,7 +50,7 @@ import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTermi
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
@@ -1118,7 +1118,7 @@ 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, primaryFontFamily, 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 });
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 });
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

@@ -43,11 +43,23 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
return (
<input
ref={inputRef}
data-inline-group-edit="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onBlur={() => {
queueMicrotask(() => {
commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
@@ -60,7 +72,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
}
}}
className={cn(
'min-w-0 flex-1 truncate rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const root = new URL('../..', import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), 'utf8');
}
test('DebouncedTextarea keeps draft locally and commits on debounce/blur', () => {
const source = readProjectFile('components/settings/DebouncedTextarea.tsx');
assert.match(source, /useState\(value\)/);
assert.match(source, /onDraftChangeRef\.current\?\.\(next\)/);
assert.match(source, /setTimeout\(\(\) =>/);
assert.match(source, /onBlur/);
assert.match(source, /onCommitRef\.current\(draft\)/);
assert.match(source, /draftRef\.current !== committedRef\.current/);
});
test('settings appearance uses debounced custom CSS textarea with live preview', () => {
const source = readProjectFile('components/settings/tabs/SettingsAppearanceTab.tsx');
assert.match(source, /DebouncedTextarea/);
assert.match(source, /applyCustomCssToDocument/);
assert.match(source, /onDraftChange=\{applyCustomCssToDocument\}/);
});

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
interface DebouncedTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
value: string;
onCommit: (value: string) => void;
/** Fires on every keystroke before the debounced commit (e.g. live CSS preview). */
onDraftChange?: (value: string) => void;
debounceMs?: number;
}
/**
* Keeps typing responsive by holding draft text locally and committing upstream
* after a short pause — avoids re-rendering the full settings tree per keystroke.
*/
export const DebouncedTextarea: React.FC<DebouncedTextareaProps> = ({
value,
onCommit,
onDraftChange,
debounceMs = 300,
className,
...props
}) => {
const [draft, setDraft] = useState(value);
const draftRef = useRef(value);
const committedRef = useRef(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCommitRef = useRef(onCommit);
const onDraftChangeRef = useRef(onDraftChange);
onCommitRef.current = onCommit;
onDraftChangeRef.current = onDraftChange;
draftRef.current = draft;
committedRef.current = value;
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
return () => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
if (draftRef.current !== committedRef.current) {
onCommitRef.current(draftRef.current);
}
};
}, []);
const scheduleCommit = (next: string) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
commitTimerRef.current = null;
onCommitRef.current(next);
}, debounceMs);
};
return (
<textarea
{...props}
value={draft}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
onDraftChangeRef.current?.(next);
scheduleCommit(next);
}}
onBlur={() => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
if (draft !== value) {
onCommitRef.current(draft);
}
}}
className={cn(className)}
/>
);
};

View File

@@ -2,7 +2,6 @@ import React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
import { ScrollArea } from "../ui/scroll-area";
import { TabsContent } from "../ui/tabs";
interface ToggleProps {
@@ -195,8 +194,8 @@ export const SettingsTabContent: React.FC<{
children: React.ReactNode;
}> = ({ value, children }) => (
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="h-full overflow-y-auto overflow-x-hidden">
<div className="p-6 space-y-6">{children}</div>
</ScrollArea>
</div>
</TabsContent>
);

View File

@@ -1,4 +1,6 @@
import React, { useCallback } from "react";
import React, { memo, useCallback } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
@@ -9,7 +11,7 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
import { FontSelect } from "../FontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
export default function SettingsAppearanceTab(props: {
function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
@@ -369,9 +371,10 @@ export default function SettingsAppearanceTab(props: {
<p className="text-xs text-muted-foreground">
{t("settings.appearance.customCss.desc")}
</p>
<textarea
<DebouncedTextarea
value={customCSS}
onChange={(e) => setCustomCSS(e.target.value)}
onCommit={setCustomCSS}
onDraftChange={applyCustomCssToDocument}
placeholder={t("settings.appearance.customCss.placeholder")}
className="w-full h-32 px-3 py-2 text-xs font-mono bg-muted/50 border border-border rounded-lg resize-y focus:outline-none focus:ring-2 focus:ring-primary/50"
spellCheck={false}
@@ -380,3 +383,5 @@ export default function SettingsAppearanceTab(props: {
</SettingsTabContent>
);
}
export default memo(SettingsAppearanceTab);

View File

@@ -1019,4 +1019,4 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
);
};
export default SettingsSystemTab;
export default React.memo(SettingsSystemTab);

View File

@@ -27,7 +27,7 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
export default function SettingsTerminalTab(props: {
function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
@@ -1015,3 +1015,5 @@ export default function SettingsTerminalTab(props: {
</SettingsTabContent>
);
}
export default React.memo(SettingsTerminalTab);

View File

@@ -1,148 +1,526 @@
/**
* Terminal Compose Bar
* An immersive, borderless prompt bar that blends into the terminal's
* background — like the Claude Code compose area. Enter sends, Escape
* closes, Shift+Enter inserts a newline. The only visible chrome is a
* hair-line top border separating it from the terminal output.
* An immersive prompt bar below the terminal with a quick-snippet strip,
* user-resizable height, and terminal-matched chrome.
*/
import { Radio, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef } from 'react';
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
import { Snippet } from '../../types';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import {
buildSnippetIdKey,
filterComposeBarSnippets,
mergeComposeBarSnippetMap,
resolveComposeBarDefaultSeedIds,
} from './composeBarHelpers';
const SNIPPET_STRIP_HEIGHT = 30;
const RESIZE_HANDLE_HEIGHT = 6;
type ComposeBarTheme = {
resolvedBg: string;
resolvedFg: string;
borderColor: string;
mutedFg: string;
hoverBg: string;
chipBg: string;
chipHoverBg: string;
};
function buildTheme(themeColors?: { background: string; foreground: string }): ComposeBarTheme {
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
return {
resolvedBg,
resolvedFg,
borderColor: `color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
mutedFg: `color-mix(in srgb, ${resolvedFg} 55%, ${resolvedBg} 45%)`,
hoverBg: `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
chipBg: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
chipHoverBg: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
};
}
interface ComposeBarSnippetChipProps {
snippet: Snippet;
theme: ComposeBarTheme;
onActivate: (snippet: Snippet, sendImmediately: boolean) => void;
onUnpin: (id: string) => void;
unpinLabel: string;
clickHint: string;
}
const ComposeBarSnippetChip = memo(function ComposeBarSnippetChip({
snippet,
theme,
onActivate,
onUnpin,
unpinLabel,
clickHint,
}: ComposeBarSnippetChipProps) {
const commandPreview = snippet.command.split('\n')[0];
return (
<div
className="group/chip flex items-stretch h-6 max-w-[168px] rounded overflow-hidden flex-shrink-0 transition-colors duration-150"
style={{ backgroundColor: theme.chipBg, color: theme.resolvedFg }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = theme.chipHoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = theme.chipBg;
}}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 min-w-0 px-2 text-[10px] font-mono truncate text-left"
onClick={(e) => { void onActivate(snippet, e.shiftKey); }}
>
{snippet.label}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">{snippet.label}</p>
<p className="text-[10px] opacity-80 mt-0.5 font-mono line-clamp-2">
{commandPreview}
</p>
<p className="text-[10px] opacity-60 mt-1">{clickHint}</p>
</TooltipContent>
</Tooltip>
<button
type="button"
className={cn(
'flex items-center justify-center w-5 shrink-0',
'opacity-40 hover:opacity-100 group-hover/chip:opacity-70',
'transition-opacity duration-150',
)}
style={{ color: theme.mutedFg }}
aria-label={unpinLabel}
onClick={(e) => {
e.stopPropagation();
onUnpin(snippet.id);
}}
>
<X size={9} />
</button>
</div>
);
});
interface ComposeBarSnippetManagePopoverProps {
snippets: Snippet[];
pinnedCount: number;
theme: ComposeBarTheme;
isPinned: (id: string) => boolean;
onTogglePin: (id: string) => void;
manageLabel: string;
searchPlaceholder: string;
noSnippetsLabel: string;
noMatchingLabel: string;
pinnedCountLabel: string;
}
const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopover({
snippets,
pinnedCount,
theme,
isPinned,
onTogglePin,
manageLabel,
searchPlaceholder,
noSnippetsLabel,
noMatchingLabel,
pinnedCountLabel,
}: ComposeBarSnippetManagePopoverProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const filteredSnippets = useMemo(
() => filterComposeBarSnippets(snippets, search),
[snippets, search],
);
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch('');
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="h-6 w-6 flex-shrink-0 flex items-center justify-center rounded transition-colors duration-150"
style={{ color: theme.mutedFg }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = theme.hoverBg;
e.currentTarget.style.color = theme.resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = theme.mutedFg;
}}
aria-label={manageLabel}
title={manageLabel}
>
<Plus size={12} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-0"
align="end"
side="top"
sideOffset={6}
style={{
backgroundColor: theme.resolvedBg,
borderColor: theme.borderColor,
color: theme.resolvedFg,
}}
>
<div
className="px-2.5 py-2 border-b"
style={{ borderColor: theme.borderColor }}
>
<p className="text-[11px] font-semibold mb-1.5">{manageLabel}</p>
<div
className="flex items-center gap-1.5 rounded px-2 h-7"
style={{ backgroundColor: theme.chipBg }}
>
<Search size={11} style={{ color: theme.mutedFg }} className="shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 min-w-0 bg-transparent text-[11px] font-mono outline-none placeholder:opacity-60"
style={{ color: theme.resolvedFg }}
/>
</div>
</div>
<div className="max-h-52 overflow-y-auto p-1">
{snippets.length === 0 ? (
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
{noSnippetsLabel}
</p>
) : filteredSnippets.length === 0 ? (
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
{noMatchingLabel}
</p>
) : (
filteredSnippets.map((snippet) => {
const pinned = isPinned(snippet.id);
return (
<button
key={snippet.id}
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors duration-150"
style={{
color: theme.resolvedFg,
backgroundColor: pinned ? theme.chipHoverBg : 'transparent',
}}
onMouseEnter={(e) => {
if (!pinned) e.currentTarget.style.backgroundColor = theme.hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = pinned ? theme.chipHoverBg : 'transparent';
}}
onClick={() => onTogglePin(snippet.id)}
>
<Pin
size={11}
className="shrink-0"
style={{
color: pinned ? theme.resolvedFg : theme.mutedFg,
fill: pinned ? 'currentColor' : 'none',
}}
/>
<span className="flex-1 min-w-0 truncate text-[11px] font-mono">
{snippet.label}
</span>
</button>
);
})
)}
</div>
{pinnedCount > 0 && (
<div
className="px-2.5 py-1.5 border-t text-[10px]"
style={{ borderColor: theme.borderColor, color: theme.mutedFg }}
>
{pinnedCountLabel}
</div>
)}
</PopoverContent>
</Popover>
);
});
export interface TerminalComposeBarProps {
onSend: (text: string) => void;
onClose: () => void;
isBroadcastEnabled?: boolean;
themeColors?: {
background: string;
foreground: string;
};
onSend: (text: string) => void;
onClose: () => void;
onSnippetClick?: (snippet: Snippet) => void;
snippets?: Snippet[];
isBroadcastEnabled?: boolean;
themeColors?: {
background: string;
foreground: string;
};
}
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSend,
onClose,
isBroadcastEnabled,
themeColors,
onSend,
onClose,
onSnippetClick,
snippets = [],
isBroadcastEnabled,
themeColors,
}) => {
const { t } = useI18n();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const { t } = useI18n();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
const heightRef = useRef(barHeight);
// Auto-focus on mount
useEffect(() => {
// Small delay to ensure the element is rendered
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
const snippetIdKey = useMemo(
() => buildSnippetIdKey(snippets.map((snippet) => snippet.id)),
[snippets],
);
const defaultSeedIds = useMemo(
() => resolveComposeBarDefaultSeedIds(snippets),
[snippets],
);
const { pinnedIds, unpin, toggle, isPinned } = useComposeBarPinnedSnippets(
snippetIdKey,
defaultSeedIds,
);
// Auto-resize textarea
const handleInput = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, []);
heightRef.current = barHeight;
const handleSend = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
const text = el.value;
if (!text) return;
onSend(text);
el.value = '';
el.style.height = 'auto';
el.focus();
}, [onSend]);
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, onClose]);
const snippetsById = useMemo(
() => mergeComposeBarSnippetMap(snippets),
[snippets],
);
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
const pinnedSnippets = useMemo(
() => pinnedIds
.map((id) => snippetsById.get(id))
.filter((snippet): snippet is Snippet => Boolean(snippet)),
[pinnedIds, snippetsById],
);
return (
<div
className="flex-shrink-0"
style={{
backgroundColor: resolvedBg,
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
padding: '8px 12px',
}}
>
<div className="flex items-center gap-2">
{/* Broadcast indicator */}
{isBroadcastEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
</TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.broadcasting")}</TooltipContent>
</Tooltip>
)}
const clickHint = t('terminal.composeBar.snippetClickHint');
{/* Borderless input — lives flush on the terminal bg so the
bar feels like part of the terminal rather than a panel. */}
<textarea
ref={textareaRef}
className={cn(
"flex-1 min-w-0 resize-none bg-transparent border-none px-0 py-0",
"text-xs font-mono leading-relaxed outline-none",
"placeholder:opacity-70",
)}
style={{
color: resolvedFg,
minHeight: '20px',
maxHeight: '120px',
}}
rows={1}
placeholder={t("terminal.composeBar.placeholder")}
onInput={handleInput}
onKeyDown={handleKeyDown}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
/>
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
{/* Minimal close button — no filled bg, hover only. */}
<Tooltip>
<TooltipTrigger asChild>
<button
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
style={{
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
e.currentTarget.style.color = resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
}}
onClick={onClose}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.close")}</TooltipContent>
</Tooltip>
</div>
useEffect(() => () => {
resizeCleanupRef.current?.();
}, []);
const handleSend = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
const text = el.value;
if (!text) return;
onSend(text);
el.value = '';
el.focus();
}, [onSend]);
const insertCommand = useCallback((command: string) => {
const el = textareaRef.current;
if (!el) return;
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
el.value = el.value ? `${el.value}${prefix}${command}` : command;
el.focus();
}, []);
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
if (sendImmediately) {
if (onSnippetClick) {
onSnippetClick(snippet);
} else {
const command = await resolveSnippetCommand(snippet);
if (command !== null) onSend(command);
}
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
insertCommand(command);
}, [insertCommand, onSend, onSnippetClick]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, onClose]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
resizeCleanupRef.current?.();
const startY = e.clientY;
const startHeight = heightRef.current;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientY - startY;
setBarHeight(startHeight - delta);
};
const cleanup = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
resizeCleanupRef.current = null;
};
const onUp = () => {
persistBarHeight(heightRef.current);
cleanup();
};
resizeCleanupRef.current = cleanup;
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [persistBarHeight, setBarHeight]);
return (
<div
className="flex-shrink-0 flex flex-col"
style={{
height: barHeight,
backgroundColor: theme.resolvedBg,
borderTop: `1px solid ${theme.borderColor}`,
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('terminal.composeBar.resize')}
className="flex-shrink-0 flex items-center justify-center cursor-ns-resize group"
style={{ height: RESIZE_HANDLE_HEIGHT }}
onMouseDown={handleResizeStart}
>
<GripHorizontal
size={12}
className="opacity-0 group-hover:opacity-60 transition-opacity"
style={{ color: theme.mutedFg }}
/>
</div>
<div
className="flex-shrink-0 flex items-center gap-1 px-2 min-w-0"
style={{ height: SNIPPET_STRIP_HEIGHT }}
>
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-x-auto scrollbar-thin">
{pinnedSnippets.map((snippet) => (
<ComposeBarSnippetChip
key={snippet.id}
snippet={snippet}
theme={theme}
clickHint={clickHint}
unpinLabel={t('terminal.composeBar.unpinSnippet', { label: snippet.label })}
onUnpin={unpin}
onActivate={handleSnippetActivate}
/>
))}
</div>
);
<ComposeBarSnippetManagePopover
snippets={snippets}
pinnedCount={pinnedSnippets.length}
theme={theme}
isPinned={isPinned}
onTogglePin={toggle}
manageLabel={t('terminal.composeBar.manageSnippets')}
searchPlaceholder={t('terminal.composeBar.searchSnippets')}
noSnippetsLabel={t('terminal.toolbar.noSnippets')}
noMatchingLabel={t('terminal.composeBar.noMatchingSnippets')}
pinnedCountLabel={t('terminal.composeBar.pinnedCount', { count: pinnedSnippets.length })}
/>
</div>
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
<div className="flex flex-1 min-h-0 items-start gap-1.5">
{isBroadcastEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
</Tooltip>
)}
<textarea
ref={textareaRef}
className={cn(
'flex-1 min-w-0 min-h-0 h-full resize-none bg-transparent border-none px-0 py-0',
'text-xs font-mono leading-relaxed outline-none',
'placeholder:opacity-70 overflow-y-auto',
)}
style={{ color: theme.resolvedFg }}
placeholder={t('terminal.composeBar.placeholder')}
onKeyDown={handleKeyDown}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
style={{
color: theme.mutedFg,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = theme.hoverBg;
e.currentTarget.style.color = theme.resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = theme.mutedFg;
}}
onClick={onClose}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.close')}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
};
export default TerminalComposeBar;

View File

@@ -378,6 +378,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
executeSnippetCommand(text, false);
}
}}
onSnippetClick={(snippet) => void executeSnippet(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
termRef.current?.focus();

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Snippet } from '../../types';
import {
buildSnippetIdKey,
clampComposeBarHeight,
COMPOSE_BAR_BUILTIN_SNIPPET_IDS,
COMPOSE_BAR_MAX_HEIGHT,
COMPOSE_BAR_MIN_HEIGHT,
filterComposeBarSnippets,
resolveComposeBarDefaultSeedIds,
} from './composeBarHelpers';
const sampleSnippets: Snippet[] = [
{ id: 'a', label: 'List files', command: 'ls -la', package: 'utils' },
{ id: 'b', label: 'Disk usage', command: 'df -h', package: 'monitor' },
{ id: 'c', label: 'Restart nginx', command: 'systemctl restart nginx' },
];
test('clampComposeBarHeight clamps below minimum', () => {
assert.equal(clampComposeBarHeight(10), COMPOSE_BAR_MIN_HEIGHT);
});
test('clampComposeBarHeight clamps above maximum', () => {
assert.equal(clampComposeBarHeight(999), COMPOSE_BAR_MAX_HEIGHT);
});
test('clampComposeBarHeight passes through valid values', () => {
assert.equal(clampComposeBarHeight(150), 150);
});
test('filterComposeBarSnippets returns all snippets sorted when query is empty', () => {
assert.deepEqual(
filterComposeBarSnippets(sampleSnippets, '').map((s) => s.id),
['b', 'a', 'c'],
);
});
test('filterComposeBarSnippets filters by label, command, and package', () => {
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'nginx').map((s) => s.id), ['c']);
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'df').map((s) => s.id), ['b']);
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'utils').map((s) => s.id), ['a']);
});
test('filterComposeBarSnippets returns empty list when nothing matches', () => {
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'missing'), []);
});
test('buildSnippetIdKey joins ids with a null delimiter', () => {
assert.equal(buildSnippetIdKey(['a', 'b']), 'a\0b');
});
test('resolveComposeBarDefaultSeedIds uses vault snippets when available', () => {
assert.deepEqual(
resolveComposeBarDefaultSeedIds(sampleSnippets).map((id) => id),
['b', 'a', 'c'],
);
});
test('resolveComposeBarDefaultSeedIds falls back to built-ins when vault is empty', () => {
assert.deepEqual(
resolveComposeBarDefaultSeedIds([]),
COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, 4),
);
});

View File

@@ -0,0 +1,57 @@
import type { Snippet } from '../../types';
export const COMPOSE_BAR_DEFAULT_HEIGHT = 120;
export const COMPOSE_BAR_MIN_HEIGHT = 72;
export const COMPOSE_BAR_MAX_HEIGHT = 360;
export function clampComposeBarHeight(height: number): number {
return Math.max(COMPOSE_BAR_MIN_HEIGHT, Math.min(COMPOSE_BAR_MAX_HEIGHT, height));
}
export function filterComposeBarSnippets(snippets: Snippet[], query: string): Snippet[] {
const normalized = query.trim().toLowerCase();
const filtered = normalized
? snippets.filter((snippet) => (
snippet.label.toLowerCase().includes(normalized)
|| snippet.command.toLowerCase().includes(normalized)
|| (snippet.package?.toLowerCase().includes(normalized) ?? false)
))
: snippets;
return filtered.slice().sort((a, b) => a.label.localeCompare(b.label));
}
export function buildSnippetIdKey(snippetIds: readonly string[]): string {
return snippetIds.join('\0');
}
/** Built-in quick commands shown until the user customizes the strip. */
export const COMPOSE_BAR_BUILTIN_SNIPPETS: Snippet[] = [
{ id: '__compose_builtin_ls', label: 'ls -la', command: 'ls -la' },
{ id: '__compose_builtin_df', label: 'df -h', command: 'df -h' },
{ id: '__compose_builtin_free', label: 'free -h', command: 'free -h' },
{ id: '__compose_builtin_pwd', label: 'pwd', command: 'pwd' },
];
export const COMPOSE_BAR_BUILTIN_SNIPPET_IDS = COMPOSE_BAR_BUILTIN_SNIPPETS.map((s) => s.id);
export const COMPOSE_BAR_DEFAULT_SEED_COUNT = 4;
export function resolveComposeBarDefaultSeedIds(snippets: Snippet[]): string[] {
if (snippets.length > 0) {
return filterComposeBarSnippets(snippets, '')
.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT)
.map((snippet) => snippet.id);
}
return COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT);
}
export function mergeComposeBarSnippetMap(snippets: Snippet[]): Map<string, Snippet> {
const map = new Map<string, Snippet>();
for (const builtin of COMPOSE_BAR_BUILTIN_SNIPPETS) {
map.set(builtin.id, builtin);
}
for (const snippet of snippets) {
map.set(snippet.id, snippet);
}
return map;
}

View File

@@ -28,6 +28,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
import { logger } from "../../../lib/logger";
import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
@@ -213,11 +214,8 @@ const csiParamsInclude = (
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
* include fallback fonts. Used by autocomplete and other helpers that need
* the first face without the CJK / icon fallback stack.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
@@ -278,13 +276,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
const kittyKeyboardMode = createKittyKeyboardModeState();
const resolvedFontWeightBold = (() => {
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: fontFamily,
normalWeight: fontWeight,
desiredBoldWeight: fontWeightBold,
fontSize: effectiveFontSize,
});
const term = new XTerm({
...performanceConfig.options,

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
type TerminalEffectsContext = Record<string, any>;
@@ -47,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, primaryFontFamily, 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, 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.
@@ -453,16 +454,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
| 700
| 800
| 900;
const resolvedFontWeightBold = (() => {
const fontFamily = termRef.current?.options.fontFamily || "";
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
})();
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: termRef.current?.options.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
@@ -662,23 +659,22 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
const resolvedBold = resolveFontWeightBold({
fontFamilyCss: termRef.current.options?.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
const id = sessionRef.current;

View File

@@ -420,9 +420,9 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
color: theme.termFg,
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
}}
draggable={canDrag}
draggable={canDrag && !isInlineEditing}
onDragStart={(event) => {
if (!canDrag) return;
if (!canDrag || isInlineEditing) return;
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
event.dataTransfer.effectAllowed = 'move';
}}
@@ -445,8 +445,12 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
onMouseLeave={(event) => {
if (!isDragOver) event.currentTarget.style.backgroundColor = '';
}}
onClick={() => onTogglePath(node.path)}
onClick={() => {
if (isInlineEditing) return;
onTogglePath(node.path);
}}
onKeyDown={(event) => {
if (isInlineEditing) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onTogglePath(node.path);
@@ -711,6 +715,24 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
handleDropToParent(null, event.dataTransfer);
}, [canDrag, handleDropToParent]);
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!menuActions) return;
const editingGroupPath = inlineEdit?.groupPath;
const editingHostId = inlineHostEdit?.hostId;
if (!editingGroupPath && !editingHostId) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-inline-group-edit="true"]')) return;
const row = target.closest('[data-section="terminal-host-tree-sidebar-row"]');
if (!row) return;
if (editingGroupPath && row.getAttribute('data-group-path') === editingGroupPath) return;
if (editingHostId && row.getAttribute('data-host-id') === editingHostId) return;
if (editingGroupPath) menuActions.cancelInlineGroupEdit();
if (editingHostId) menuActions.cancelInlineHostEdit();
}, [inlineEdit?.groupPath, inlineHostEdit?.hostId, menuActions]);
useEffect(() => {
if (!inlineEdit?.shouldScrollIntoView || !inlineEdit.isNew) return;
const index = flatRows.findIndex(
@@ -971,6 +993,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
className="flex-1 min-h-0 py-1"
data-section="terminal-host-tree-sidebar-content"
style={dragOverTarget?.kind === 'root' ? { backgroundColor: theme.rowDropBg } : undefined}
onPointerDownCapture={handleListPointerDownCapture}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}

View File

@@ -198,6 +198,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onToggleWorkspaceViewModeRef: s.onToggleWorkspaceViewModeRef,
prevFocusedSessionIdRef,
previewTargetSessionId: themeState.previewTargetSessionId,
refocusActiveTerminalSession: s.refocusActiveTerminalSession,
requestAnimationFrame,
ResizeObserver,
sessionActivityStore: s.sessionActivityStore,

View File

@@ -16,11 +16,13 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
composeBarThemeColors,
focusedSessionId,
handleComposeSend,
handleSnippetFromPanel,
isBroadcastEnabled,
isComposeBarOpen,
isTerminalLayerVisible,
refocusTerminalSession,
setIsComposeBarOpen,
snippets,
TerminalComposeBar,
workspaceOuterRef,
} = ctx;
@@ -46,6 +48,8 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
refocusTerminalSession(focusedSessionId);

View File

@@ -6,7 +6,7 @@ import { terminalLayoutSuppressStore } from '../../application/state/terminalLay
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, 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, 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;
useEffect(() => {
const liveSessionIds = new Set(sessions.map((session) => session.id));
@@ -310,6 +310,23 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}, [isFocusMode, dropHint]);
const wasTerminalLayerVisibleRef = useRef(false);
const prevActiveTabIdRef = useRef<string | undefined>(undefined);
// Restore keyboard focus to the active terminal after switching work tabs.
useEffect(() => {
if (!isTerminalLayerVisible) {
prevActiveTabIdRef.current = activeTabId;
return;
}
const tabChanged =
prevActiveTabIdRef.current !== undefined &&
prevActiveTabIdRef.current !== activeTabId;
prevActiveTabIdRef.current = activeTabId;
if (!tabChanged) return;
refocusActiveTerminalSession?.();
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state

View File

@@ -43,6 +43,10 @@ export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
/** Inline snippet script editor height (px) in vault edit panel. */
export const STORAGE_KEY_SNIPPET_SCRIPT_EDITOR_HEIGHT = 'netcatty_snippet_script_editor_height_v1';
/** Terminal compose bar total height (px). */
export const STORAGE_KEY_COMPOSE_BAR_HEIGHT = 'netcatty_compose_bar_height_v1';
/** Snippet IDs pinned to the terminal compose bar quick strip. */
export const STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS = 'netcatty_compose_bar_pinned_snippets_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
// Update check

23
lib/customCss.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const root = new URL('..', import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), 'utf8');
}
test('custom CSS helper uses a single stable style element id', () => {
const source = readProjectFile('lib/customCss.ts');
assert.match(source, /netcatty-custom-css/);
assert.match(source, /styleEl\.textContent = css/);
});
test('settings state applies custom CSS through the shared helper', () => {
const source = readProjectFile('application/state/useSettingsState.ts');
assert.match(source, /applyCustomCssToDocument\(customCSS\)/);
});

14
lib/customCss.ts Normal file
View File

@@ -0,0 +1,14 @@
const CUSTOM_CSS_STYLE_ID = 'netcatty-custom-css';
/** Inject or update the user custom CSS style block in document.head. */
export function applyCustomCssToDocument(css: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = CUSTOM_CSS_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
}

View File

@@ -22,7 +22,7 @@
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
'JetBrains Mono', // @fontsource/jetbrains-mono (regular, 500, 600)
'JetBrains Mono', // @fontsource/jetbrains-mono (400, 500, 600)
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
]);

View File

@@ -0,0 +1,93 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
isBoldWeightDistinctWithContext,
pickNearestBundledWeight,
resolveFontWeightBold,
} from './fontWeightAvailability';
function makeWeightContext(weightsByFamily: Record<string, Partial<Record<number, number>>>) {
return {
measureText: (font: string, text: string) => {
const match = font.match(/^(\d+)\s+\d+px\s+"?([^",]+)"?,/);
const weight = match ? Number(match[1]) : 400;
const family = match?.[2] ?? '';
const width = weightsByFamily[family]?.[weight] ?? weightsByFamily[family]?.[400] ?? 100;
return {
width: width * text.length,
actualBoundingBoxAscent: 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
}
describe('pickNearestBundledWeight', () => {
it('returns the desired weight when bundled', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 600, 400), 600);
});
it('falls back to the nearest heavier bundled weight', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 700, 400), 600);
});
it('returns normal weight when nothing heavier is bundled', () => {
assert.equal(pickNearestBundledWeight([400], 700, 400), 400);
});
});
describe('isBoldWeightDistinctWithContext', () => {
it('detects a real bold face via width differences', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 12 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('detects a real bold face via ascent differences', () => {
const ctx = {
measureText: (font: string, text: string) => {
const isBold = font.startsWith('700 ');
return {
width: text.length * 10,
actualBoundingBoxAscent: isBold ? 12 : 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('rejects unavailable bold weights that collapse to the normal face', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 10 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), false);
});
});
describe('resolveFontWeightBold', () => {
it('caps bundled JetBrains Mono bold at 600 when 700 is requested', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 400,
desiredBoldWeight: 700,
fontSize: 14,
}),
600,
);
});
it('returns normal weight when bold is not heavier than normal', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 600,
desiredBoldWeight: 500,
fontSize: 14,
}),
600,
);
});
});

View File

@@ -0,0 +1,104 @@
import { extractPrimaryFamily } from './fontAvailability';
/** Weights actually shipped via @fontsource in index.tsx. */
export const BUNDLED_FONT_WEIGHTS: Readonly<Record<string, readonly number[]>> = {
'JetBrains Mono': [400, 500, 600],
};
export type FontWeightMeasureContext = {
measureText: (font: string, text: string) => TextMetrics;
};
const BOLD_PROBE = 'WMwm0123456789';
export function pickNearestBundledWeight(
available: readonly number[],
desired: number,
normal: number,
): number {
if (available.includes(desired)) return desired;
const heavier = available.filter((weight) => weight > normal);
if (heavier.length === 0) return normal;
return heavier.reduce((best, weight) =>
Math.abs(weight - desired) < Math.abs(best - desired) ? weight : best,
);
}
/**
* True when rendering `boldWeight` produces measurably different glyphs than
* `normalWeight` for `family`. Unlike document.fonts.check(), this does not
* false-positive on syntactically valid but unavailable families/weights in
* Chromium (see fontAvailability.ts).
*/
export function isBoldWeightDistinctWithContext(
family: string,
normalWeight: number,
boldWeight: number,
fontSize: number,
ctx: FontWeightMeasureContext,
): boolean {
if (boldWeight <= normalWeight) return false;
const quoted = /\s/.test(family) ? `"${family}"` : family;
const normalFont = `${normalWeight} ${fontSize}px ${quoted}, monospace`;
const boldFont = `${boldWeight} ${fontSize}px ${quoted}, monospace`;
const normalMetrics = ctx.measureText(normalFont, BOLD_PROBE);
const boldMetrics = ctx.measureText(boldFont, BOLD_PROBE);
if (Math.abs(boldMetrics.width - normalMetrics.width) > 0.01) return true;
const normalAscent = normalMetrics.actualBoundingBoxAscent ?? 0;
const boldAscent = boldMetrics.actualBoundingBoxAscent ?? 0;
if (Math.abs(boldAscent - normalAscent) > 0.01) return true;
const normalDescent = normalMetrics.actualBoundingBoxDescent ?? 0;
const boldDescent = boldMetrics.actualBoundingBoxDescent ?? 0;
return Math.abs(boldDescent - normalDescent) > 0.01;
}
function buildBrowserMeasureContext(): FontWeightMeasureContext | null {
if (typeof document === 'undefined') return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return {
measureText: (font, text) => {
ctx.font = font;
return ctx.measureText(text);
},
};
}
/**
* Resolve the boldest weight xterm can safely rasterize for the primary font.
* Falls back to `normalWeight` when the requested bold face is unavailable.
*/
export function resolveFontWeightBold(args: {
fontFamilyCss: string;
normalWeight: number;
desiredBoldWeight: number;
fontSize: number;
}): number {
const { fontFamilyCss, normalWeight, desiredBoldWeight, fontSize } = args;
if (desiredBoldWeight <= normalWeight) return normalWeight;
const primary = extractPrimaryFamily(fontFamilyCss);
const bundled = BUNDLED_FONT_WEIGHTS[primary];
if (bundled) {
return pickNearestBundledWeight(bundled, desiredBoldWeight, normalWeight);
}
const ctx = buildBrowserMeasureContext();
if (!ctx) return desiredBoldWeight;
return isBoldWeightDistinctWithContext(
primary,
normalWeight,
desiredBoldWeight,
fontSize,
ctx,
)
? desiredBoldWeight
: normalWeight;
}

View File

@@ -35,7 +35,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",