Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6ca3f477 | ||
|
|
1c9c4fcec3 | ||
|
|
8f68e24057 | ||
|
|
2374f67ffc | ||
|
|
fea8e8b305 | ||
|
|
79a7e460be | ||
|
|
f48db8ee4e | ||
|
|
ba2a0389fa | ||
|
|
6309a49c37 | ||
|
|
b1291d3ee2 | ||
|
|
18c001e9c5 | ||
|
|
c2c6b265d4 | ||
|
|
1e50b66407 | ||
|
|
2fb2155d79 | ||
|
|
3429c498f9 | ||
|
|
dc7b14e323 | ||
|
|
5d675b9cef | ||
|
|
bf9f0e1fc2 | ||
|
|
02967d9258 | ||
|
|
343176120e | ||
|
|
c0b4dace87 | ||
|
|
b6e8d63fef | ||
|
|
60c07da140 | ||
|
|
f89afc0e05 | ||
|
|
ca0b1ed9ae | ||
|
|
555438a02a | ||
|
|
97e78624bb | ||
|
|
eab1e8db67 | ||
|
|
8e6392e503 | ||
|
|
8b99f2411f | ||
|
|
98905b9c81 | ||
|
|
b7e1df9916 | ||
|
|
3089cab88d |
29
App.tsx
29
App.tsx
@@ -307,6 +307,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
@@ -893,13 +899,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs]
|
||||
: ['vault', ...orderedTabs];
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
}
|
||||
@@ -907,8 +918,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'nextTab': {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
@@ -920,8 +929,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'prevTab': {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
@@ -968,7 +975,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
case 'openSftp':
|
||||
setActiveTabId('sftp');
|
||||
if (settings.showSftpTab) {
|
||||
setActiveTabId('sftp');
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
case 'commandPalette':
|
||||
@@ -1056,7 +1065,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1424,6 +1433,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
@@ -1469,6 +1479,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
||||
onRunSnippet={runSnippet}
|
||||
onOpenLogView={openLogView}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
/>
|
||||
@@ -1582,6 +1594,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
|
||||
@@ -202,6 +202,10 @@ const en: Messages = {
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -1152,7 +1156,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
@@ -1740,12 +1744,16 @@ const en: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
@@ -1756,7 +1764,7 @@ const en: Messages = {
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
|
||||
@@ -186,6 +186,10 @@ const zhCN: Messages = {
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -765,7 +769,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端',
|
||||
'terminal.toolbar.search': '搜索',
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
@@ -1748,12 +1752,16 @@ const zhCN: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
@@ -1764,7 +1772,7 @@ const zhCN: Messages = {
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -71,6 +73,9 @@ const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -260,6 +265,18 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
|
||||
});
|
||||
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
|
||||
});
|
||||
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -463,6 +480,12 @@ export const useSettingsState = () => {
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -662,6 +685,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
@@ -671,6 +695,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
@@ -834,6 +859,24 @@ export const useSettingsState = () => {
|
||||
setSftpDefaultViewMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showRecentHosts) {
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(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';
|
||||
@@ -923,6 +966,27 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowRecentHosts = useCallback((enabled: boolean) => {
|
||||
setShowRecentHostsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
|
||||
setShowOnlyUngroupedHostsInRootState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
@@ -1228,6 +1292,12 @@ export const useSettingsState = () => {
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1266,6 +1336,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,6 +175,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
|
||||
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
if (showRecent != null) settings.showRecentHosts = showRecent;
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -238,6 +244,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
|
||||
// Immersive mode (legacy — always enabled, ignore incoming value)
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -480,6 +481,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isCodexManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
|
||||
// so the picker can show just that model instead of the hardcoded ChatGPT
|
||||
// preset list. Probing codex-acp for its full catalog returns the stock
|
||||
// OpenAI models regardless of the active provider, which is misleading.
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void bridge.aiCodexGetIntegration().then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
// Only flip "resolved" to true when the probe confirms this is a
|
||||
// custom-config session; otherwise keep it false so we fall back to
|
||||
// the static CODEX_MODEL_PRESETS.
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isCodexManagedAgent, currentAgentId]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -520,10 +557,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
const agentModelPresets = useMemo(
|
||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
|
||||
);
|
||||
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
||||
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
||||
// codexCustomConfigResolved (declared above alongside codexConfigModel)
|
||||
// stays false until the integration probe confirms this session is
|
||||
// custom-config, so we don't flash an empty picker while loading.
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
if (hasCodexCustomConfig) {
|
||||
// Config.toml with a pinned model → show just that model.
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
// Config.toml custom provider without a pinned model → codex-acp
|
||||
// uses its provider default. Don't surface the OpenAI presets; they
|
||||
// wouldn't work. Empty list disables the picker.
|
||||
return [];
|
||||
}
|
||||
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
|
||||
@@ -70,6 +70,7 @@ interface QuickSwitcherProps {
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -161,7 +163,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
// Tabs (built-in + sessions + workspaces)
|
||||
items.push({ type: "tab", id: "vault" });
|
||||
items.push({ type: "tab", id: "sftp" });
|
||||
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
|
||||
orphanSessions.forEach((s) =>
|
||||
items.push({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
@@ -194,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
@@ -317,7 +319,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
|
||||
@@ -286,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
setShowRecentHosts={settings.setShowRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
@@ -245,6 +246,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
||||
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
|
||||
// chained xterm.write callbacks verify the token before proceeding so a
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
@@ -684,6 +690,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
retryTokenRef.current = null;
|
||||
cleanupSession();
|
||||
xtermRuntimeRef.current?.dispose();
|
||||
xtermRuntimeRef.current = null;
|
||||
@@ -1398,6 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
retryTokenRef.current = null;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
@@ -1417,6 +1425,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
retryTokenRef.current = null;
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
@@ -1458,10 +1467,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleRetry = () => {
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
// Reset terminal state: disable mouse tracking modes and clear screen so
|
||||
// stale SGR mouse sequences don't leak into the new session as text input.
|
||||
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
|
||||
termRef.current.reset();
|
||||
const term = termRef.current;
|
||||
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
||||
// kicks off another retry while the chained writes below are still
|
||||
// queued, the token will be invalidated and our callbacks will abort
|
||||
// before opening a ghost backend session with no owning UI.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
|
||||
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
@@ -1470,17 +1484,51 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setShowLogs(true);
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(termRef.current);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(termRef.current);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(termRef.current);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(termRef.current);
|
||||
} else {
|
||||
sessionStarters.startSSH(termRef.current);
|
||||
}
|
||||
|
||||
const startNewSession = () => {
|
||||
if (!retryStillActive()) return;
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(term);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(term);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(term);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(term);
|
||||
} else {
|
||||
sessionStarters.startSSH(term);
|
||||
}
|
||||
};
|
||||
|
||||
// Chain the whole preparation through xterm.write callbacks so everything
|
||||
// lands in strict order — see #695. xterm.write is async, so without
|
||||
// chaining, a fast reconnect path (local/serial especially) can interleave
|
||||
// the new session's first bytes with our reset sequence, corrupting the
|
||||
// first screen.
|
||||
//
|
||||
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
|
||||
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
|
||||
// we must be on the normal buffer before preserving.
|
||||
term.write('\x1b[?1049l', () => {
|
||||
if (!retryStillActive()) return;
|
||||
// 2. Push the previous session's viewport into scrollback so the user
|
||||
// can still read it after reconnect.
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
|
||||
// full-screen apps may have left on — DECCKM (otherwise arrow keys
|
||||
// emit SS3 and break readline history), keypad mode, SGR,
|
||||
// insert/replace, origin, cursor visibility — without clearing the
|
||||
// buffer. DECSTR does not cover xterm-specific extensions, so also
|
||||
// explicitly disable mouse tracking (1000/1002/1003/1006) and
|
||||
// bracketed paste (2004). Finally home the cursor.
|
||||
term.write(
|
||||
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
|
||||
// 4. Only now — after every prep byte has been applied to the
|
||||
// terminal — start the new session, so its first output can't
|
||||
// interleave with the reset sequence.
|
||||
startNewSession,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
|
||||
@@ -44,6 +44,7 @@ interface TopTabsProps {
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -251,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -812,40 +814,42 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
>
|
||||
<FolderLock size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
{showSftpTab && (
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable tabs container with fade masks */}
|
||||
@@ -969,7 +973,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
prev.isImmersiveActive === next.isImmersiveActive &&
|
||||
prev.showSftpTab === next.showSftpTab
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@ import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig"
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import {
|
||||
@@ -147,6 +151,8 @@ interface VaultViewProps {
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
// Optional: navigate to a specific section on mount or when changed
|
||||
navigateToSection?: VaultSection | null;
|
||||
onNavigateToSectionHandled?: () => void;
|
||||
@@ -193,6 +199,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onRunSnippet,
|
||||
groupConfigs,
|
||||
onUpdateGroupConfigs,
|
||||
showRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
navigateToSection,
|
||||
onNavigateToSectionHandled,
|
||||
}) => {
|
||||
@@ -230,11 +238,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
|
||||
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
if (navigateToSection) {
|
||||
@@ -874,6 +877,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
@@ -911,7 +919,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
|
||||
}, [hosts, selectedGroupPath, showOnlyUngroupedHostsInRoot, search, selectedTags, sortMode]);
|
||||
|
||||
// Pinned hosts for root-level display (not inside a subgroup)
|
||||
// Respects active search and tag filters
|
||||
@@ -962,6 +970,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// No longer deduplicate pinned/recent hosts from the main list,
|
||||
// so hosts always appear in their groups regardless of pinned/recent status.
|
||||
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
|
||||
const visibleDisplayedHosts = useMemo(
|
||||
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
|
||||
[displayedHosts, selectedGroupPath, pinnedRecentIds],
|
||||
);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
@@ -1125,6 +1137,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
|
||||
}, [buildGroupTree, selectedGroupPath, customGroups]);
|
||||
const shouldHideEmptyRootHostsSection = useMemo(() => {
|
||||
if (selectedGroupPath || viewMode === "tree") return false;
|
||||
if (search.trim() || selectedTags.length > 0) return false;
|
||||
if (visibleDisplayedHosts.length > 0) return false;
|
||||
return (
|
||||
displayedGroups.length > 0 ||
|
||||
pinnedHosts.length > 0 ||
|
||||
(showRecentHosts && recentHosts.length > 0)
|
||||
);
|
||||
}, [
|
||||
selectedGroupPath,
|
||||
viewMode,
|
||||
search,
|
||||
selectedTags.length,
|
||||
visibleDisplayedHosts.length,
|
||||
displayedGroups.length,
|
||||
pinnedHosts.length,
|
||||
showRecentHosts,
|
||||
recentHosts.length,
|
||||
]);
|
||||
|
||||
// Known Hosts callbacks - use refs to keep stable references
|
||||
// Store latest values in refs so callbacks don't need to depend on them
|
||||
@@ -2353,6 +2385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!shouldHideEmptyRootHostsSection && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
@@ -2360,7 +2393,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
@@ -2622,7 +2655,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}
|
||||
>
|
||||
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
{visibleDisplayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -2754,6 +2787,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentSection === "snippets" && (
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -166,10 +169,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
|
||||
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
|
||||
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
|
||||
// split on the first '/'. Match against the full id first; only treat the
|
||||
// trailing segment as a thinking level when we find a preset whose
|
||||
// declared thinkingLevels make the combined form equal to selectedModelId.
|
||||
const { selectedPreset, selectedThinking } = (() => {
|
||||
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
const direct = modelPresets.find(m => m.id === selectedModelId);
|
||||
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
|
||||
const viaThinking = modelPresets.find(
|
||||
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
|
||||
);
|
||||
if (viaThinking) {
|
||||
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
|
||||
return { selectedPreset: viaThinking, selectedThinking: thinking };
|
||||
}
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
@@ -375,7 +394,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
if (rect) {
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
@@ -395,8 +420,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
@@ -420,12 +445,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
|
||||
@@ -30,7 +30,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -556,16 +556,13 @@ export function useAIChatStreaming({
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
// Resolve the correct provider based on agent type:
|
||||
// - Claude agent → anthropic provider (prefer over generic custom)
|
||||
// - Codex agent → openai provider
|
||||
// - Codex agent → openai provider (fallback to openai-compatible custom)
|
||||
const agentProviderId = (() => {
|
||||
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
|
||||
return (
|
||||
context.providers.find(p => p.providerId === 'anthropic' && p.enabled && p.apiKey)?.id
|
||||
?? context.providers.find(p => p.providerId === 'custom' && p.enabled && p.apiKey && p.baseURL)?.id
|
||||
);
|
||||
return findManagedAgentProvider(context.providers, 'claude')?.id;
|
||||
}
|
||||
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
|
||||
return context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey)?.id;
|
||||
return findManagedAgentProvider(context.providers, 'codex')?.id;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
findManagedAgentProvider,
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
@@ -304,18 +305,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex"));
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
@@ -524,9 +523,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
hasCompatibleProvider={hasCodexCompatibleProvider}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
@@ -27,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
showRecentHosts: boolean;
|
||||
setShowRecentHosts: (enabled: boolean) => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -47,13 +51,14 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
} = props;
|
||||
|
||||
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
|
||||
const hexToHsl = useCallback((hex: string) => {
|
||||
@@ -269,6 +274,21 @@ export default function SettingsAppearanceTab(props: {
|
||||
>
|
||||
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
|
||||
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={showOnlyUngroupedHostsInRoot}
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
description={t('settings.vault.showSftpTabDesc')}
|
||||
>
|
||||
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
|
||||
@@ -15,7 +15,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
hasCompatibleProvider: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -31,7 +31,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
hasCompatibleProvider,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
@@ -42,6 +42,14 @@ export const CodexConnectionCard: React.FC<{
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const customConfigIncomplete = Boolean(
|
||||
integration?.state === "connected_custom_config"
|
||||
&& integration.customConfig
|
||||
&& integration.customConfig.envKey
|
||||
&& !integration.customConfig.envKeyPresent
|
||||
&& !integration.customConfig.hasHardcodedApiKey,
|
||||
);
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
@@ -52,9 +60,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
: integration?.state === "connected_custom_config"
|
||||
? customConfigIncomplete
|
||||
? t('ai.codex.customConfigIncomplete')
|
||||
: t('ai.codex.connectedCustomConfig')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
@@ -62,9 +74,11 @@ export const CodexConnectionCard: React.FC<{
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
: customConfigIncomplete
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
@@ -139,6 +153,9 @@ export const CodexConnectionCard: React.FC<{
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.state === "connected_custom_config" ? (
|
||||
// Nothing to log out of; config.toml is user-owned state.
|
||||
null
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
@@ -157,7 +174,26 @@ export const CodexConnectionCard: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
{integration?.state === "connected_custom_config" && integration.customConfig && (
|
||||
<>
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.customConfigHint').replace(
|
||||
'{provider}',
|
||||
integration.customConfig.displayName || integration.customConfig.providerName,
|
||||
)}
|
||||
</p>
|
||||
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.customConfigMissingEnvKey').replace(
|
||||
'{envKey}',
|
||||
integration.customConfig.envKey,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
|
||||
@@ -10,14 +10,27 @@ import type {
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "connected_custom_config"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: CodexCustomProviderConfig | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
@@ -57,7 +70,7 @@ export interface FetchBridge {
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
|
||||
@@ -328,12 +328,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startSSH = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.backendAvailable()) {
|
||||
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
|
||||
term.writeln(
|
||||
@@ -717,12 +711,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.telnetAvailable()) {
|
||||
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
|
||||
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
|
||||
@@ -756,12 +744,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startMosh = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.moshAvailable()) {
|
||||
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
|
||||
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
|
||||
@@ -812,12 +794,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startLocal = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.localAvailable()) {
|
||||
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
|
||||
term.writeln(
|
||||
|
||||
@@ -425,12 +425,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!consumed) return false; // Event was consumed by autocomplete
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
|
||||
e.preventDefault();
|
||||
ctx.setIsSearchOpen(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentScheme = ctx.hotkeySchemeRef.current;
|
||||
// Use shared utility for platform detection when hotkey scheme is disabled
|
||||
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
|
||||
|
||||
@@ -396,7 +396,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
|
||||
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
|
||||
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
|
||||
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
|
||||
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
|
||||
|
||||
// Navigation / Split View
|
||||
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
|
||||
|
||||
@@ -206,6 +206,10 @@ export interface SyncPayload {
|
||||
immersiveMode?: boolean;
|
||||
// Vault: show recently connected hosts
|
||||
showRecentHosts?: boolean;
|
||||
// Vault: root list shows only ungrouped hosts
|
||||
showOnlyUngroupedHostsInRoot?: boolean;
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
showSftpTab?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { existsSync, readFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
|
||||
@@ -124,6 +125,212 @@ function getActiveCodexLoginSession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Codex config.toml probing ──
|
||||
//
|
||||
// Users who hand-configure `~/.codex/config.toml` with a custom
|
||||
// `model_provider` + matching `[model_providers.<name>]` entry are fully
|
||||
// functional from the Codex CLI, but `codex login status` doesn't see them
|
||||
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
|
||||
// We read and minimally parse the config file so we can surface this as a
|
||||
// valid "ready" state and skip the ChatGPT login prompt in the UI.
|
||||
|
||||
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
|
||||
* than peeking at the previous character, so even runs of backslashes like
|
||||
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
|
||||
* strings don't recognize `\` as an escape, so only honor escapes inside
|
||||
* basic (double-quoted) strings. */
|
||||
function findUnquotedHash(value) {
|
||||
let inStr = false;
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
inStr = false;
|
||||
quote = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inStr = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the narrow subset of TOML we need from Codex's config.toml:
|
||||
* - top-level string keys (e.g. `model_provider = "my_provider"`)
|
||||
* - `[model_providers.<name>]` tables with string-valued keys
|
||||
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
|
||||
* are ignored — Codex's config.toml doesn't use them for provider definitions.
|
||||
*/
|
||||
function parseCodexConfigToml(text) {
|
||||
const result = { model_providers: {} };
|
||||
let currentProvider = null;
|
||||
let atTopLevel = true;
|
||||
|
||||
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
|
||||
const normalized = String(text || "").replace(/^\uFEFF/, "");
|
||||
const lines = normalized.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
let line = rawLine;
|
||||
const hashIdx = findUnquotedHash(line);
|
||||
if (hashIdx >= 0) line = line.slice(0, hashIdx);
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const section = sectionMatch[1].trim();
|
||||
if (section.startsWith("model_providers.")) {
|
||||
currentProvider = section.slice("model_providers.".length);
|
||||
if (!result.model_providers[currentProvider]) {
|
||||
result.model_providers[currentProvider] = {};
|
||||
}
|
||||
atTopLevel = false;
|
||||
} else {
|
||||
currentProvider = null;
|
||||
atTopLevel = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
|
||||
if (!kvMatch) continue;
|
||||
const key = kvMatch[1];
|
||||
let raw = kvMatch[2].trim();
|
||||
let value;
|
||||
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
||||
value = raw.slice(1, -1);
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
|
||||
if (atTopLevel) {
|
||||
result[key] = value;
|
||||
} else if (currentProvider) {
|
||||
result.model_providers[currentProvider][key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `~/.codex/config.toml` to determine whether the user has
|
||||
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
|
||||
* path.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the config file doesn't exist or can't be read
|
||||
* - no `model_provider` is set, or it points to the default `openai` preset
|
||||
* - the referenced provider entry is missing (config is malformed)
|
||||
*
|
||||
* Returns a summary object otherwise — even if the env_key isn't currently
|
||||
* exported in the shell environment. That case is surfaced via
|
||||
* `envKeyPresent: false` so the UI can warn the user; we don't want the
|
||||
* absence of an env var to silently fall back to the ChatGPT login flow,
|
||||
* because the config.toml is a strong signal the user doesn't want that.
|
||||
*/
|
||||
function readCodexCustomProviderConfig(shellEnv) {
|
||||
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
||||
if (!home) return null;
|
||||
const configPath = path.join(home, ".codex", "config.toml");
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseCodexConfigToml(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeName = typeof parsed.model_provider === "string"
|
||||
? parsed.model_provider.trim()
|
||||
: "";
|
||||
if (!activeName) return null;
|
||||
// The built-in "openai" provider still goes through ChatGPT/API-key auth
|
||||
// managed by `codex login`, so treating it as "custom" would be wrong.
|
||||
if (activeName === "openai") return null;
|
||||
|
||||
const providerEntry = parsed.model_providers?.[activeName];
|
||||
if (!providerEntry) return null;
|
||||
|
||||
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
|
||||
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
|
||||
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
|
||||
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
|
||||
|
||||
// Hash the actual auth material (either the hardcoded api_key or the
|
||||
// resolved env_key value) so the ACP provider fingerprint changes when
|
||||
// the user rotates their key — without ever returning the raw value
|
||||
// across the IPC boundary.
|
||||
const authMaterial = hardcodedApiKey || envKeyValue;
|
||||
const authHash = authMaterial
|
||||
? createHash("sha256").update(authMaterial).digest("hex")
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerName: activeName,
|
||||
displayName: providerEntry.name || activeName,
|
||||
baseUrl: providerEntry.base_url || null,
|
||||
envKey: envKeyName || null,
|
||||
envKeyPresent: Boolean(envKeyValue),
|
||||
hasHardcodedApiKey: Boolean(hardcodedApiKey),
|
||||
model: activeModel || null,
|
||||
authHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-facing error message when a Codex config.toml custom
|
||||
* provider references an env_key that isn't exported in the shell env and
|
||||
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
|
||||
* by every spawn path (stream handler, list-models handler) so users get
|
||||
* the same actionable message regardless of which one hits first.
|
||||
*/
|
||||
function getCodexCustomConfigPreflightError(customConfig) {
|
||||
if (!customConfig) return null;
|
||||
if (!customConfig.envKey) return null;
|
||||
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
|
||||
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ACP auth override object for Codex spawn sites.
|
||||
* - netcatty-managed API key present → "codex-api-key"
|
||||
* - user's own ~/.codex/config.toml custom provider detected → no override
|
||||
* (so codex-acp resolves auth from the shell env / config itself)
|
||||
* - otherwise → "chatgpt" (triggers the browser OAuth login flow)
|
||||
*
|
||||
* Returned as an object designed to be spread into createACPProvider options.
|
||||
*/
|
||||
function getCodexAuthOverride(apiKey, shellEnv) {
|
||||
if (apiKey) return { authMethodId: "codex-api-key" };
|
||||
if (readCodexCustomProviderConfig(shellEnv)) return {};
|
||||
return { authMethodId: "chatgpt" };
|
||||
}
|
||||
|
||||
// ── Integration state ──
|
||||
|
||||
function normalizeCodexIntegrationState(rawOutput) {
|
||||
@@ -199,6 +406,9 @@ module.exports = {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
|
||||
@@ -211,6 +211,15 @@ async function getShellEnv() {
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
|
||||
* login shell. Useful when the user has just exported a new variable in
|
||||
* their rc file and clicks "Refresh Status" without restarting the app.
|
||||
*/
|
||||
function invalidateShellEnvCache() {
|
||||
_cachedShellEnv = null;
|
||||
}
|
||||
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
@@ -316,5 +325,6 @@ module.exports = {
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
toUnpackedAsarPath,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
@@ -35,6 +36,9 @@ const {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
@@ -234,6 +238,34 @@ function resolveProviderApiKey(providerId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) {
|
||||
const parts = [
|
||||
typeof apiKey === "string" ? apiKey.trim() : "",
|
||||
typeof provider?.id === "string" ? provider.id.trim() : "",
|
||||
typeof provider?.providerId === "string" ? provider.providerId.trim() : "",
|
||||
typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "",
|
||||
customConfig
|
||||
? [
|
||||
"custom",
|
||||
customConfig.providerName || "",
|
||||
customConfig.baseUrl || "",
|
||||
customConfig.envKey || "",
|
||||
customConfig.envKeyPresent ? "1" : "0",
|
||||
// authHash changes when the user rotates their hardcoded api_key
|
||||
// or the env_key's resolved value; without it a cached ACP
|
||||
// provider would keep serving the stale key.
|
||||
customConfig.authHash || "",
|
||||
].join(":")
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCodexAuthFingerprint(parts.join("\n"));
|
||||
}
|
||||
|
||||
/** Check if TLS verification should be skipped for a given provider. */
|
||||
function shouldSkipTLSVerify(providerId) {
|
||||
if (!providerId) return false;
|
||||
@@ -1689,8 +1721,14 @@ function registerHandlers(ipcMain) {
|
||||
return { path: resolvedPath, version, available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// When the user clicks "Refresh Status" in Settings we also want to
|
||||
// rescan the shell env — otherwise a newly-exported variable in
|
||||
// .zshrc stays invisible until they restart netcatty entirely.
|
||||
if (options && options.refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
const result = await runCodexCli(["login", "status"]);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
@@ -1724,11 +1762,33 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// `codex login status` only reflects ~/.codex/auth.json. A user who
|
||||
// configured a custom provider directly in ~/.codex/config.toml is
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
let customConfig = null;
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput: effectiveRawOutput,
|
||||
exitCode: result.exitCode,
|
||||
customConfig,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -1736,6 +1796,7 @@ function registerHandlers(ipcMain) {
|
||||
isConnected: false,
|
||||
rawOutput: err?.message || String(err),
|
||||
exitCode: null,
|
||||
customConfig: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1847,7 +1908,10 @@ function registerHandlers(ipcMain) {
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput,
|
||||
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
@@ -2102,6 +2166,19 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
// Mirror the stream handler's pre-flight: if Codex is pointed at a
|
||||
// config.toml custom provider whose env_key is not exported, surface
|
||||
// a targeted error instead of spawning codex-acp and letting it fail
|
||||
// mid-init with an opaque message.
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const preflight = getCodexCustomConfigPreflightError(
|
||||
readCodexCustomProviderConfig(shellEnv),
|
||||
);
|
||||
if (preflight) {
|
||||
return { ok: false, models: [], error: preflight };
|
||||
}
|
||||
}
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
@@ -2143,7 +2220,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: [],
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2254,7 +2331,28 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
// Probe ~/.codex/config.toml first so we can tell a ChatGPT user
|
||||
// (needs login validation) from a custom-provider user (must NOT be
|
||||
// forced through ChatGPT validation, since their auth lives in
|
||||
// config.toml / shell env, not auth.json).
|
||||
const codexCustomConfig = isCodexAgent && !apiKey
|
||||
? readCodexCustomProviderConfig(shellEnv)
|
||||
: null;
|
||||
|
||||
// Fail loud: custom-provider config is set but has no usable auth
|
||||
// material yet (env_key is named but not exported in the shell env,
|
||||
// and no api_key is hardcoded). Don't spawn — codex-acp would fail
|
||||
// mid-request with an opaque "Missing environment variable" error.
|
||||
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
|
||||
if (preflightError) {
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: preflightError,
|
||||
});
|
||||
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
|
||||
}
|
||||
|
||||
if (isCodexAgent && !apiKey && !codexCustomConfig) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
@@ -2275,11 +2373,9 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
const authFingerprint = isCodexAgent
|
||||
? getCodexAuthFingerprint(apiKey)
|
||||
: isClaudeAgent
|
||||
? getCodexAuthFingerprint(apiKey + (resolvedProvider?.provider?.baseURL || ""))
|
||||
: null;
|
||||
const authFingerprint = isCodexAgent || isClaudeAgent
|
||||
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
|
||||
: null;
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
@@ -2388,7 +2484,7 @@ function registerHandlers(ipcMain) {
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2400,7 +2496,7 @@ function registerHandlers(ipcMain) {
|
||||
resolvedCommand,
|
||||
resolvedArgs,
|
||||
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
|
||||
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
|
||||
authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null,
|
||||
});
|
||||
|
||||
if (isCopilotAgent) {
|
||||
@@ -2496,7 +2592,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
|
||||
@@ -1184,8 +1184,8 @@ const api = {
|
||||
aiResolveCli: async (params) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
|
||||
},
|
||||
aiCodexGetIntegration: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
|
||||
aiCodexGetIntegration: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options);
|
||||
},
|
||||
aiCodexStartLogin: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
|
||||
|
||||
14
global.d.ts
vendored
14
global.d.ts
vendored
@@ -732,11 +732,21 @@ declare global {
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiCodexGetIntegration?(): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(): Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig, ProviderConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
|
||||
|
||||
@@ -67,3 +67,24 @@ export function getManagedAgentStoredPath(
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
|
||||
export function findManagedAgentProvider(
|
||||
providers: ProviderConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): ProviderConfig | undefined {
|
||||
if (agentKey === 'codex') {
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === 'openai' && provider.enabled && !!provider.apiKey)
|
||||
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
|
||||
);
|
||||
}
|
||||
|
||||
if (agentKey === 'claude') {
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === 'anthropic' && provider.enabled && !!provider.apiKey)
|
||||
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
|
||||
|
||||
// Vault: Show Recently Connected hosts section
|
||||
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
|
||||
export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only_ungrouped_hosts_in_root_v1';
|
||||
|
||||
// Top tabs: Show standalone SFTP view tab
|
||||
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
|
||||
|
||||
// Group Configurations (default settings inherited by hosts)
|
||||
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';
|
||||
|
||||
Reference in New Issue
Block a user