Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bbe62a1d | ||
|
|
57e131a16e | ||
|
|
ea6f9e138c | ||
|
|
5177ce2028 | ||
|
|
9f44112479 | ||
|
|
6999f362a3 | ||
|
|
fc546c2430 | ||
|
|
f7e4953038 | ||
|
|
922376fa06 | ||
|
|
3d4ca46c9b | ||
|
|
1d8f203f5b | ||
|
|
41d079a806 | ||
|
|
93c95959d3 | ||
|
|
e7300429f8 | ||
|
|
c7743d082a | ||
|
|
56a4fe905d | ||
|
|
b17775307f | ||
|
|
be7aa4ae52 | ||
|
|
f4872099bd | ||
|
|
4e2089d7e2 | ||
|
|
5f28320c57 | ||
|
|
4e26852482 | ||
|
|
c4fb19cafb | ||
|
|
09e6526142 | ||
|
|
7ce110c3fb | ||
|
|
667ee18ed3 | ||
|
|
f969b1b73d | ||
|
|
58a4bf892a | ||
|
|
5052a8231f | ||
|
|
13c9cf16fd | ||
|
|
63558b5301 | ||
|
|
c2b4d43531 | ||
|
|
4d5c0eed69 | ||
|
|
3ad710e5da | ||
|
|
d2e5a26317 | ||
|
|
4f1eb4a8a9 | ||
|
|
e35bb708a2 | ||
|
|
cd2631428e | ||
|
|
09af399543 | ||
|
|
a9a648039f | ||
|
|
1d4ec7afb9 | ||
|
|
a1899951e0 | ||
|
|
d84668aa0f | ||
|
|
68d0f4574c | ||
|
|
bedf59bb48 | ||
|
|
793ea94078 |
3
.gitignore
vendored
@@ -37,6 +37,9 @@ coverage
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Codex
|
||||
/.codex/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
|
||||
30
App.tsx
@@ -185,6 +185,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
@@ -1067,31 +1068,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save session log if enabled
|
||||
if (sessionLogsEnabled && sessionLogsDir && data) {
|
||||
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.autoSaveSessionLog) {
|
||||
bridge.autoSaveSessionLog({
|
||||
terminalData: data,
|
||||
hostLabel: matchingLog.hostLabel,
|
||||
hostname: matchingLog.hostname,
|
||||
hostId: matchingLog.hostId,
|
||||
startTime: matchingLog.startTime,
|
||||
format: sessionLogsFormat,
|
||||
directory: sessionLogsDir,
|
||||
}).then(result => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
|
||||
}).catch(err => {
|
||||
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Auto-save is now handled by real-time streaming in the main process
|
||||
// via sessionLogStreamManager. No renderer-side fallback needed.
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
@@ -1324,8 +1306,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [コントリビューション](#コントリビューション)
|
||||
- [コントリビューター](#コントリビューター)
|
||||
- [Star 履歴](#star-履歴)
|
||||
- [ライセンス](#ライセンス)
|
||||
|
||||
---
|
||||
@@ -110,37 +112,37 @@
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくださったすべての方に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-履歴"></a>
|
||||
# Star 履歴
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
50
README.md
@@ -59,6 +59,8 @@
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [Star History](#star-history)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
@@ -111,37 +113,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +215,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="getting-started"></a>
|
||||
@@ -309,7 +328,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +341,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
|
||||
|
||||
---
|
||||
|
||||
<a name="star-history"></a>
|
||||
# Star History
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [构建与打包](#构建与打包)
|
||||
- [技术栈](#技术栈)
|
||||
- [参与贡献](#参与贡献)
|
||||
- [贡献者](#贡献者)
|
||||
- [Star 历史](#star-历史)
|
||||
- [开源协议](#开源协议)
|
||||
|
||||
---
|
||||
@@ -111,37 +113,37 @@
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="快速开始"></a>
|
||||
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-历史"></a>
|
||||
# Star 历史
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -5,6 +5,9 @@ const en: Messages = {
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.reset': 'Reset',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.settings': 'Settings',
|
||||
'common.search': 'Search',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
@@ -745,6 +748,13 @@ const en: Messages = {
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
@@ -1548,7 +1558,7 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
|
||||
@@ -5,6 +5,9 @@ const zhCN: Messages = {
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.reset': '重置',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '缩小',
|
||||
'common.settings': '设置',
|
||||
'common.search': '搜索',
|
||||
'common.connect': '连接',
|
||||
@@ -1060,6 +1063,13 @@ const zhCN: Messages = {
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
|
||||
// Settings > SFTP 自动打开侧栏
|
||||
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
@@ -1563,7 +1573,7 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
|
||||
79
application/state/useFileUpload.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* useImageUpload - Handle image paste/drop with base64 conversion
|
||||
*
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:image/...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png"
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageUpload() {
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
|
||||
const addImages = useCallback(async (files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newImages: UploadedImage[] = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `screenshot-${Date.now()}.png`;
|
||||
const mediaType = file.type || 'image/png';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useImageUpload] Failed to convert:', err);
|
||||
}
|
||||
return { id, filename, dataUrl, base64Data, mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setImages([]);
|
||||
}, []);
|
||||
|
||||
return { images, addImages, removeImage, clearImages };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
@@ -63,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -231,6 +233,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'false' || stored === 'disabled') return false;
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
@@ -393,6 +399,8 @@ export const useSettingsState = () => {
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -529,6 +537,9 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -694,6 +705,13 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(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';
|
||||
@@ -712,7 +730,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -797,6 +815,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
@@ -1019,6 +1043,8 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
@@ -1052,7 +1078,7 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useImageUpload } from '../application/state/useImageUpload';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
@@ -104,6 +104,11 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
@@ -179,6 +184,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -196,16 +202,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
const scopeTypeRef = useRef(scopeType);
|
||||
scopeTypeRef.current = scopeType;
|
||||
const scopeTargetIdRef = useRef(scopeTargetId);
|
||||
scopeTargetIdRef.current = scopeTargetId;
|
||||
const scopeLabelRef = useRef(scopeLabel);
|
||||
scopeLabelRef.current = scopeLabel;
|
||||
const resolveExecutorContextRef = useRef(resolveExecutorContext);
|
||||
resolveExecutorContextRef.current = resolveExecutorContext;
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
@@ -405,8 +407,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
@@ -416,11 +418,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
const getExecutorContext = useCallback((): ExecutorContext => ({
|
||||
sessions: terminalSessionsRef.current,
|
||||
workspaceId: scopeTypeRef.current === 'workspace' ? scopeTargetIdRef.current : undefined,
|
||||
workspaceName: scopeTypeRef.current === 'workspace' ? scopeLabelRef.current : undefined,
|
||||
}), []);
|
||||
const buildExecutorContextForScope = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}): ExecutorContext => {
|
||||
const resolved = resolveExecutorContextRef.current?.(scope);
|
||||
if (resolved) return resolved;
|
||||
return {
|
||||
sessions: terminalSessionsRef.current,
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
@@ -455,16 +465,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
clearFiles();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
@@ -487,7 +497,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
@@ -504,6 +514,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
@@ -514,19 +529,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, getExecutorContext, setPendingApproval,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -639,19 +654,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
@@ -700,9 +707,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
|
||||
@@ -61,16 +61,6 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
@@ -100,7 +90,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
const hostsCountInNode = useMemo(() => countAllHostsInNode(node), [node]);
|
||||
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
|
||||
@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="start" alignToParent>
|
||||
<DropdownContent className="w-48" align="start" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
|
||||
@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async () => {
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const content = await readKnownHosts();
|
||||
if (content === undefined) {
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
t("knownHosts.toast.scanUnavailable"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKnownHostsFile(content);
|
||||
if (parsed.length === 0) {
|
||||
toast.info(
|
||||
if (!silent) toast.info(
|
||||
t("knownHosts.toast.scanNoEntries"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
onImportFromFile(newHosts);
|
||||
toast.success(
|
||||
if (!silent) toast.success(
|
||||
t("knownHosts.toast.scanImported", { count: newHosts.length }),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
} else {
|
||||
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to scan system known_hosts:", err);
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
}
|
||||
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
|
||||
|
||||
// Auto-scan on first mount
|
||||
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
|
||||
useEffect(() => {
|
||||
if (!hasScannedRef.current) {
|
||||
hasScannedRef.current = true;
|
||||
// Delay scan slightly to not block initial render
|
||||
const timer = setTimeout(() => {
|
||||
handleScanSystem();
|
||||
handleScanSystem(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
@@ -152,6 +152,8 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
|
||||
// Helper function to format network speed (bytes/sec) to human-readable format
|
||||
@@ -209,6 +211,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
@@ -487,6 +490,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
|
||||
@@ -108,8 +108,13 @@ interface TerminalLayerProps {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
// Session log settings for real-time streaming
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -153,8 +158,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -167,8 +176,56 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCloseSession(sessionId);
|
||||
}, [onCloseSession]);
|
||||
|
||||
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
|
||||
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
|
||||
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
|
||||
const proto = session.protocol;
|
||||
if (proto !== 'ssh' && proto !== 'mosh') return;
|
||||
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
|
||||
// Determine the tab ID (workspace or solo session)
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
|
||||
// Only open if the sidebar is not already open for this tab
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
}
|
||||
: {
|
||||
// Quick Connect / temporary session — build minimal host from session data
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
} as Host;
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'sftp');
|
||||
return next;
|
||||
});
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
|
||||
@@ -244,6 +301,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
activeTabIdRef.current = activeTabId;
|
||||
const activeWorkspaceRef = useRef(activeWorkspace);
|
||||
activeWorkspaceRef.current = activeWorkspace;
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
|
||||
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
|
||||
|
||||
@@ -965,6 +1028,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => {
|
||||
const latestWorkspaces = workspacesRef.current;
|
||||
const latestSessions = sessionsRef.current;
|
||||
const latestHosts = hostsRef.current;
|
||||
const sessionIds = scope.type === 'workspace'
|
||||
? (() => {
|
||||
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
|
||||
return workspace?.root ? collectSessionIds(workspace.root) : [];
|
||||
})()
|
||||
: scope.targetId ? [scope.targetId] : [];
|
||||
|
||||
const workspaceName = scope.type === 'workspace'
|
||||
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessions: sessionIds.map((sid) => {
|
||||
const session = latestSessions.find((s) => s.id === sid);
|
||||
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: session?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || session?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
}),
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -1360,6 +1461,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1512,6 +1614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1613,6 +1716,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
|
||||
@@ -689,6 +689,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const countAllHostsInNode = useCallback((node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
node.totalHostCount = count;
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
@@ -712,8 +721,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
customGroups.forEach((path) => insertPath(path));
|
||||
hosts.forEach((host) => insertPath(host.group || "General", host));
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
}, [hosts, customGroups, countAllHostsInNode]);
|
||||
|
||||
// Generate all possible group paths from the tree (including all intermediate nodes)
|
||||
const allGroupPaths = useMemo(() => {
|
||||
@@ -896,19 +908,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups, countAllHostsInNode]);
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
@@ -1749,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedImage } from '../../application/state/useImageUpload';
|
||||
import type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -40,12 +40,12 @@ interface ChatInputProps {
|
||||
selectedModelId?: string;
|
||||
/** Callback when user selects a model */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
/** Attached images */
|
||||
images?: UploadedImage[];
|
||||
/** Callback to add images (paste/drop) */
|
||||
onAddImages?: (files: File[]) => void;
|
||||
/** Callback to remove an image */
|
||||
onRemoveImage?: (id: string) => void;
|
||||
/** Attached files (images, PDFs, etc.) */
|
||||
files?: UploadedFile[];
|
||||
/** Callback to add files (paste/drop) */
|
||||
onAddFiles?: (files: File[]) => void;
|
||||
/** Callback to remove a file */
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
files = [],
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
onAddFiles?.(pastedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length > 0) {
|
||||
onAddImages?.(files);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length > 0) {
|
||||
onAddFiles?.(droppedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={img.id}
|
||||
key={file.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
>
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{img.filename}</span>
|
||||
{file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
onClick={() => onRemoveFile?.(file.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X size={8} />
|
||||
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
onAddFiles?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -28,6 +29,70 @@ interface ChatMessageListProps {
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragPos = useRef({ x: 0, y: 0 });
|
||||
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
|
||||
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
|
||||
if (!imgRef.current) return;
|
||||
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
|
||||
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
|
||||
}, []);
|
||||
|
||||
const zoomRef = useRef(100);
|
||||
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
|
||||
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
|
||||
}, []);
|
||||
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -10 : 10;
|
||||
setZoomAndRef(z => {
|
||||
const nz = Math.max(25, Math.min(200, z + delta));
|
||||
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
|
||||
return nz;
|
||||
});
|
||||
}, [applyTransform, setZoomAndRef]);
|
||||
const openPreview = useCallback((src: string, name: string) => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
setPreview({ src, name });
|
||||
}, []);
|
||||
|
||||
const resetPreview = useCallback(() => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
applyTransform(100, 0, 0, true);
|
||||
}, [applyTransform]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
|
||||
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
|
||||
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
|
||||
dragPos.current = { x, y };
|
||||
applyTransform(zoomRef.current, x, y, false);
|
||||
}, [applyTransform]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
|
||||
setDragged(true);
|
||||
}
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
const resolvedToolCallIds = new Set(
|
||||
@@ -36,6 +101,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
@@ -49,6 +124,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-1.5 px-4 py-2">
|
||||
{visibleMessages.map((message) => {
|
||||
@@ -58,7 +134,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
@@ -83,16 +159,27 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
{/* User attachments (images, files) — fallback to legacy `images` field */}
|
||||
{isUser && (message.attachments ?? message.images)?.length && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{message.images.map((img, i) => (
|
||||
<img
|
||||
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
|
||||
src={`data:${img.mediaType};base64,${img.base64Data}`}
|
||||
alt={img.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
{(message.attachments ?? message.images)!.map((att, i) => (
|
||||
att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
src={`data:${att.mediaType};base64,${att.base64Data}`}
|
||||
alt={att.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -139,7 +226,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
|
||||
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{message.errorInfo.message}
|
||||
</p>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
@@ -162,6 +251,83 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
|
||||
>
|
||||
{/* Title bar: filename | zoom controls | close — all in one flex row */}
|
||||
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
|
||||
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={resetPreview}
|
||||
disabled={zoom === 100 && !dragged}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.reset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= 25}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomOut')}
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomIn')}
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreview(null)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Image area with drag support */}
|
||||
{preview && (
|
||||
<div
|
||||
className="overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
height: 'calc(min(90vh, 700px) - 40px)',
|
||||
cursor: 'grab',
|
||||
// Clamp aspect ratio: if image is extremely tall/wide, the container
|
||||
// constrains it; object-contain handles the rest.
|
||||
aspectRatio: 'auto',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onWheel={onWheel}
|
||||
onLostPointerCapture={endDrag}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
draggable={false}
|
||||
className="select-none max-w-full max-h-full object-contain"
|
||||
style={{ transition: 'transform 0.25s ease' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
@@ -27,7 +28,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 { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
@@ -135,6 +136,9 @@ export interface PendingApprovalContext {
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
getExecutorContext: () => ExecutorContext;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
@@ -196,6 +200,7 @@ export interface UseAIChatStreamingReturn {
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
@@ -294,8 +299,6 @@ export function useAIChatStreaming({
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
|
||||
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -591,20 +594,25 @@ export function useAIChatStreaming({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string) => {
|
||||
updateLastMessage(sessionId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
|
||||
updateLastMessage(sessionId, msg => {
|
||||
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
|
||||
// Only patch tool call name if the existing name is missing/generic
|
||||
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
|
||||
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
|
||||
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
});
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
@@ -667,16 +675,17 @@ export function useAIChatStreaming({
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const toolContext = context.getExecutorContext ?? (() => ({
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
|
||||
}));
|
||||
const tools = createCattyTools(
|
||||
bridge,
|
||||
toolContext,
|
||||
getExecutorContext,
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
@@ -740,7 +749,22 @@ export function useAIChatStreaming({
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
if (messageAttachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: m.content });
|
||||
for (const att of messageAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
@@ -779,13 +803,36 @@ export function useAIChatStreaming({
|
||||
});
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: trimmed });
|
||||
for (const att of attachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
sessionId,
|
||||
scopeKey: sendScopeKey,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
TerminalSessionInfo,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
@@ -75,10 +74,6 @@ export interface UseToolApprovalReturn {
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
@@ -146,7 +141,16 @@ export function useToolApproval({
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
|
||||
const {
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model: ctxModel,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
} = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
@@ -218,16 +222,20 @@ export function useToolApproval({
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale closure issues (e.g. user changed permission mode during approval wait)
|
||||
// stale settings, while keeping the original AI scope pinned to its workspace/session.
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeType === 'workspace' ? approvalContext.scopeTargetId : undefined,
|
||||
workspaceName: approvalContext.scopeType === 'workspace' ? approvalContext.scopeLabel : undefined,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
|
||||
const freshExecutorContext = getExecutorContext();
|
||||
const freshTools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
approvalContext.commandBlocklist,
|
||||
approvalContext.globalPermissionMode,
|
||||
approvalContext.webSearchConfig ?? undefined,
|
||||
);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
hosts: freshExecutorContext.sessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
@@ -246,6 +254,9 @@ export function useToolApproval({
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
@@ -253,6 +253,46 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-open sidebar section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoOpenSidebar && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoOpenSidebar.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -77,7 +77,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,6 +64,12 @@ type ChainProgressState = {
|
||||
currentHostLabel: string;
|
||||
} | null;
|
||||
|
||||
export type SessionLogConfig = {
|
||||
enabled: boolean;
|
||||
directory: string;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type TerminalSessionStartersContext = {
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
@@ -76,6 +82,7 @@ export type TerminalSessionStartersContext = {
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
serialConfig?: SerialConfig;
|
||||
sessionLog?: SessionLogConfig;
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
@@ -456,6 +463,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -609,6 +617,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
env: telnetEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
attachSessionToTerminal(ctx, term, id, {
|
||||
@@ -650,6 +659,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
env: moshEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
attachSessionToTerminal(ctx, term, id, {
|
||||
@@ -708,6 +718,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
},
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
@@ -787,6 +798,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
stopBits: ctx.serialConfig.stopBits,
|
||||
parity: ctx.serialConfig.parity,
|
||||
flowControl: ctx.serialConfig.flowControl,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
// Serial connection is established immediately when session starts
|
||||
|
||||
@@ -30,13 +30,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
|
||||
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface GroupNode {
|
||||
path: string;
|
||||
children: Record<string, GroupNode>;
|
||||
hosts: Host[];
|
||||
/** Pre-computed total host count including all descendants. Set during tree construction. */
|
||||
totalHostCount?: number;
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
|
||||
@@ -197,8 +197,9 @@ export interface SyncPayload {
|
||||
sftpAutoSync?: boolean;
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
sftpAutoOpenSidebar?: boolean;
|
||||
};
|
||||
|
||||
|
||||
// Sync metadata
|
||||
syncedAt: number; // When this payload was created
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -153,6 +154,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -211,6 +214,7 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,9 @@ module.exports = {
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@zed-industries/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/sdk/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
'node_modules/@zed-industries/codex-acp/**/*',
|
||||
'node_modules/@zed-industries/codex-acp-*/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
|
||||
@@ -141,6 +141,48 @@ async function getShellEnv() {
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
* Resolve the Claude ACP binary, returning { command, prependArgs }.
|
||||
*
|
||||
* On macOS/Linux a shebang-based .js script can be spawned directly, but on
|
||||
* Windows `child_process.spawn` does not interpret shebangs — so when the
|
||||
* resolved path is a JS file we invoke it via the system Node runtime.
|
||||
*/
|
||||
function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
|
||||
const binaryName = "claude-agent-acp";
|
||||
|
||||
// Dev mode: prefer system PATH (npm creates platform-appropriate wrappers)
|
||||
const isPackaged = electronModule?.app?.isPackaged;
|
||||
if (!isPackaged && shellEnv) {
|
||||
const systemPath = resolveCliFromPath(binaryName, shellEnv);
|
||||
if (systemPath) return { command: systemPath, prependArgs: [] };
|
||||
}
|
||||
|
||||
// Packaged build (or dev fallback): use npm-bundled binary
|
||||
try {
|
||||
const resolved = require.resolve("@zed-industries/claude-agent-acp/dist/index.js");
|
||||
const scriptPath = toUnpackedAsarPath(resolved);
|
||||
|
||||
// On Windows, .js files cannot be spawned directly (no shebang support) —
|
||||
// invoke via Node. In packaged Electron builds process.execPath is the
|
||||
// app binary (e.g. Netcatty.exe), not a Node runtime, so we must resolve
|
||||
// the real `node` from PATH. If Node is not installed, fall back to the
|
||||
// bare command name and let the system find the npm-generated .cmd wrapper.
|
||||
if (process.platform === "win32") {
|
||||
const nodePath = resolveCliFromPath("node", shellEnv);
|
||||
if (nodePath) {
|
||||
return { command: nodePath, prependArgs: [scriptPath] };
|
||||
}
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
}
|
||||
return { command: scriptPath, prependArgs: [] };
|
||||
} catch {
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stream chunk serialization ──
|
||||
|
||||
function serializeStreamChunk(chunk) {
|
||||
@@ -154,13 +196,25 @@ function serializeStreamChunk(chunk) {
|
||||
return { type: "reasoning-start", id: chunk.id ?? undefined };
|
||||
case "reasoning-end":
|
||||
return { type: "reasoning-end", id: chunk.id ?? undefined };
|
||||
case "tool-call":
|
||||
case "tool-call": {
|
||||
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool" —
|
||||
// the real tool name and args are inside chunk.args
|
||||
const isAcpWrapper = chunk.toolName === "acp.acp_provider_agent_dynamic_tool";
|
||||
const acpInput = isAcpWrapper ? chunk.input : null;
|
||||
let realToolName = isAcpWrapper ? (acpInput?.toolName || chunk.toolName) : chunk.toolName;
|
||||
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args) : chunk.args;
|
||||
const realToolCallId = isAcpWrapper ? (acpInput?.toolCallId || chunk.toolCallId) : chunk.toolCallId;
|
||||
// Simplify MCP tool names: "mcp__netcatty-remote-hosts__get_environment" → "get_environment"
|
||||
if (realToolName && realToolName.includes("__")) {
|
||||
realToolName = realToolName.split("__").pop();
|
||||
}
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: chunk.args,
|
||||
toolCallId: realToolCallId,
|
||||
toolName: realToolName,
|
||||
args: realArgs,
|
||||
};
|
||||
}
|
||||
case "tool-result":
|
||||
return {
|
||||
type: "tool-result",
|
||||
@@ -185,6 +239,7 @@ module.exports = {
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
serializeStreamChunk,
|
||||
|
||||
@@ -18,6 +18,7 @@ const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const {
|
||||
stripAnsi,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
serializeStreamChunk,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
@@ -486,6 +487,10 @@ function registerHandlers(ipcMain) {
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
// Track temporarily added HTTP hosts (for rebuild restoration)
|
||||
const tempHttpHosts = new Set();
|
||||
// Track active expiry timers per host to avoid duplicate/premature expiry
|
||||
const hostExpiryTimers = new Map();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
@@ -495,6 +500,17 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a host is owned by a provider config that uses http:// */
|
||||
function isHttpHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if (p.hostname === host && p.protocol === "http:") return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
@@ -528,17 +544,36 @@ function registerHandlers(ipcMain) {
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
if (!providerFetchHosts.has(host)) {
|
||||
const isNewHost = !providerFetchHosts.has(host);
|
||||
if (isNewHost) {
|
||||
providerFetchHosts.add(host);
|
||||
tempAllowedHosts.add(host);
|
||||
setTimeout(() => {
|
||||
// Only remove if not owned by a synced provider config
|
||||
if (!isHostInProviderConfigs(host)) {
|
||||
providerFetchHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
|
||||
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
|
||||
tempAllowedHosts.add(host);
|
||||
if (parsed.protocol === "http:") {
|
||||
providerHttpHosts.add(host);
|
||||
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
|
||||
}
|
||||
// Always (re-)schedule expiry timer to clean up temp entries
|
||||
const existing = hostExpiryTimers.get(host);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
hostExpiryTimers.delete(host);
|
||||
// Check if host is still needed by a provider config or web search
|
||||
const isWebSearchHost = webSearchApiHost && (() => {
|
||||
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
|
||||
})();
|
||||
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
|
||||
providerFetchHosts.delete(host);
|
||||
providerHttpHosts.delete(host);
|
||||
} else if (!isHttpHostInProviderConfigs(host)) {
|
||||
providerHttpHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
tempHttpHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
hostExpiryTimers.set(host, timer);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
@@ -560,6 +595,8 @@ function registerHandlers(ipcMain) {
|
||||
]);
|
||||
// Dynamically populated from configured provider baseURLs
|
||||
const providerFetchHosts = new Set();
|
||||
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
|
||||
const providerHttpHosts = new Set();
|
||||
|
||||
/**
|
||||
* Rebuild the dynamic host allowlist from the current providerConfigs.
|
||||
@@ -567,11 +604,13 @@ function registerHandlers(ipcMain) {
|
||||
*/
|
||||
function rebuildProviderFetchHosts() {
|
||||
providerFetchHosts.clear();
|
||||
providerHttpHosts.clear();
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const host of tempHttpHosts) providerHttpHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
@@ -584,6 +623,7 @@ function registerHandlers(ipcMain) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
if (parsed.protocol === "http:") providerHttpHosts.add(host);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL in config — skip
|
||||
@@ -669,16 +709,16 @@ function registerHandlers(ipcMain) {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
return ALLOWED_LOCALHOST_PORTS.has(port);
|
||||
}
|
||||
// Require HTTPS for remote hosts; allow HTTP only for the configured web search apiHost
|
||||
// (e.g. self-hosted SearXNG at http://searxng.lan:8080 or http://192.168.x.x)
|
||||
if (parsed.protocol !== "https:") {
|
||||
if (!webSearchApiHost) return false;
|
||||
try {
|
||||
const wsHost = new URL(webSearchApiHost).hostname;
|
||||
if (parsed.hostname !== wsHost) return false;
|
||||
} catch {
|
||||
return false;
|
||||
// Only allow http: and https: schemes for remote hosts
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
||||
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
|
||||
if (parsed.protocol === "http:") {
|
||||
const isProviderHost = providerHttpHosts.has(parsed.hostname);
|
||||
let isWebSearchHost = false;
|
||||
if (webSearchApiHost) {
|
||||
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
|
||||
}
|
||||
if (!isProviderHost && !isWebSearchHost) return false;
|
||||
}
|
||||
// Check built-in + provider-configured host allowlist
|
||||
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
|
||||
@@ -701,16 +741,12 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedUrl = patched.url;
|
||||
const resolvedHeaders = patched.headers;
|
||||
|
||||
// Validate URL: only allow HTTP(S) schemes; require HTTPS for non-localhost
|
||||
// Validate URL: only allow HTTP(S) schemes
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
if (parsed.protocol === "http:" && !isLocalhost) {
|
||||
return { ok: false, error: "HTTP is only allowed for localhost" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
@@ -1141,7 +1177,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover external agents from PATH, plus the bundled Codex CLI if present.
|
||||
// Discover external agents from PATH, plus bundled ACP binaries if present.
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const agents = [];
|
||||
@@ -1151,9 +1187,10 @@ function registerHandlers(ipcMain) {
|
||||
name: "Claude Code",
|
||||
icon: "claude",
|
||||
description: "Anthropic's agentic coding assistant",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
resolveAcp: resolveClaudeAcpBinaryPath,
|
||||
},
|
||||
{
|
||||
command: "codex",
|
||||
@@ -1163,6 +1200,7 @@ function registerHandlers(ipcMain) {
|
||||
acpCommand: "codex-acp",
|
||||
acpArgs: [],
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
resolveAcp: resolveCodexAcpBinaryPath,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1187,20 +1225,54 @@ function registerHandlers(ipcMain) {
|
||||
resolvedPath = null;
|
||||
}
|
||||
|
||||
// If the base command is not on PATH, check whether the bundled ACP
|
||||
// binary is available — the agent can still work via ACP without the
|
||||
// standalone CLI installed.
|
||||
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
|
||||
// resolveCodexAcpBinaryPath returns a plain string.
|
||||
let versionCommand = null;
|
||||
let versionPrependArgs = [];
|
||||
if (!resolvedPath && agent.resolveAcp) {
|
||||
const result = agent.resolveAcp(shellEnv, electronModule);
|
||||
if (typeof result === "string") {
|
||||
if (result && result !== agent.acpCommand && existsSync(result)) {
|
||||
resolvedPath = result;
|
||||
}
|
||||
} else if (result?.command) {
|
||||
// On Windows the command may be `node` with the script in prependArgs.
|
||||
// Use the script path for display/dedup so the UI shows the actual
|
||||
// agent rather than the Node binary.
|
||||
const scriptPath = result.prependArgs?.[0];
|
||||
const displayPath = scriptPath || result.command;
|
||||
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
|
||||
resolvedPath = displayPath;
|
||||
if (scriptPath) {
|
||||
versionCommand = result.command;
|
||||
versionPrependArgs = result.prependArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath || seenPaths.has(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version = "";
|
||||
try {
|
||||
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
|
||||
// When the agent is invoked via Node (Windows), probe version with
|
||||
// the full command (e.g. `node /path/to/dist/index.js --version`).
|
||||
const probeCmd = versionCommand || resolvedPath;
|
||||
const probeArgs = [...versionPrependArgs, "--version"];
|
||||
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
}
|
||||
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agent,
|
||||
...agentInfo,
|
||||
path: resolvedPath,
|
||||
version,
|
||||
available: true,
|
||||
@@ -1412,7 +1484,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// Known agent command names (must match knownAgents in discover handler)
|
||||
const ALLOWED_AGENT_COMMANDS = new Set([
|
||||
"claude", "claude-code-acp",
|
||||
"claude", "claude-agent-acp",
|
||||
"codex", "codex-acp",
|
||||
]);
|
||||
|
||||
@@ -1630,6 +1702,7 @@ function registerHandlers(ipcMain) {
|
||||
const shellEnv = await getShellEnv();
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
|
||||
// Resolve API key from providerId (decrypted in main process only)
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
@@ -1707,13 +1780,19 @@ function registerHandlers(ipcMain) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: acpCommand;
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
|
||||
const provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
args: acpArgs || [],
|
||||
args: resolvedArgs,
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
@@ -1750,11 +1829,16 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const fallbackProvider = createACPProvider({
|
||||
command: isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: acpCommand,
|
||||
args: acpArgs || [],
|
||||
: fallbackClaudeAcp
|
||||
? fallbackClaudeAcp.command
|
||||
: acpCommand,
|
||||
args: fallbackClaudeAcp
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
@@ -1802,20 +1886,52 @@ function registerHandlers(ipcMain) {
|
||||
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
|
||||
`Do NOT use local shell execution.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional images
|
||||
function buildMessageContent(text, imgs) {
|
||||
const content = [{ type: "text", text }];
|
||||
if (Array.isArray(imgs)) {
|
||||
for (const img of imgs) {
|
||||
if (!img.base64Data || !img.mediaType) continue;
|
||||
// Build message content: text + optional attachments
|
||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||
// For other file types (PDF, text, etc.), tell the agent the original file
|
||||
// path so it can read it directly — ACP agents have local file access.
|
||||
function buildMessageContent(text, attachments) {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const content = [];
|
||||
const fileHints = [];
|
||||
|
||||
for (const att of attachments) {
|
||||
if (!att.base64Data || !att.mediaType) continue;
|
||||
|
||||
if (att.mediaType.startsWith("image/")) {
|
||||
// Images: pass inline as ACP-compatible file parts
|
||||
content.push({
|
||||
type: "file",
|
||||
mediaType: img.mediaType,
|
||||
data: img.base64Data,
|
||||
...(img.filename ? { filename: img.filename } : {}),
|
||||
mediaType: att.mediaType,
|
||||
data: att.base64Data,
|
||||
...(att.filename ? { filename: att.filename } : {}),
|
||||
});
|
||||
} else if (att.filePath) {
|
||||
// Non-image files with a known local path: tell the agent to read it
|
||||
fileHints.push(`[Attached file "${att.filename || "file"}" is on the LOCAL machine (not a remote server), path: ${att.filePath} — read it locally]`);
|
||||
} else {
|
||||
// Pasted/virtual files without a path: save to managed temp dir so the agent can read them
|
||||
try {
|
||||
const fs = require("node:fs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const safeName = att.filename || `file-${Date.now()}`;
|
||||
const tempPath = tempDirBridge.getTempFilePath(safeName);
|
||||
fs.writeFileSync(tempPath, Buffer.from(att.base64Data, "base64"));
|
||||
fileHints.push(`[Attached file "${att.filename || safeName}" is on the LOCAL machine (not a remote server), path: ${tempPath} — read it locally]`);
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to save pasted attachment to temp:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = fileHints.length > 0
|
||||
? fileHints.join("\n") + "\n\n" + text
|
||||
: text;
|
||||
|
||||
content.unshift({ type: "text", text: fullText });
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,17 @@ async function renameLocalFile(event, payload) {
|
||||
* Create a local directory
|
||||
*/
|
||||
async function mkdirLocal(event, payload) {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
try {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
} catch (err) {
|
||||
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
|
||||
// If the directory already exists, that's fine — ignore the error.
|
||||
try {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
if (stat.isDirectory()) return true;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
207
electron/bridges/sessionLogStreamManager.cjs
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Session Log Stream Manager - Manages real-time log write streams per session
|
||||
* Writes terminal data to files in real-time instead of only on session close.
|
||||
* Fixes issue #394 where session logs only capture ~55 lines.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
|
||||
// Buffer flush interval (ms)
|
||||
const FLUSH_INTERVAL = 500;
|
||||
// Max buffer size before immediate flush (bytes)
|
||||
const MAX_BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Start a log stream for a session.
|
||||
* Creates the log file and opens a write stream.
|
||||
* @param {string} sessionId
|
||||
* @param {{ hostLabel: string, hostname: string, directory: string, format: string, startTime?: number }} opts
|
||||
*/
|
||||
function startStream(sessionId, opts) {
|
||||
if (activeStreams.has(sessionId)) {
|
||||
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
|
||||
stopStream(sessionId);
|
||||
}
|
||||
|
||||
const { hostLabel, hostname, directory, format, startTime } = opts;
|
||||
if (!directory) {
|
||||
console.warn("[SessionLogStream] No directory specified, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build file path: directory / hostSubdir / timestamp.ext
|
||||
const safeHostLabel = (hostLabel || hostname || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const hostDir = path.join(directory, safeHostLabel);
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
|
||||
const date = new Date(startTime || Date.now());
|
||||
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
// For html format, write raw data to a temp file during streaming,
|
||||
// then convert on stopStream.
|
||||
const isHtml = format === "html";
|
||||
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
|
||||
const fileName = `${dateStr}.${ext}`;
|
||||
const filePath = path.join(hostDir, fileName);
|
||||
|
||||
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
const entry = {
|
||||
writeStream,
|
||||
filePath,
|
||||
hostDir,
|
||||
format,
|
||||
isHtml,
|
||||
hostLabel: hostLabel || hostname || "unknown",
|
||||
startTime: startTime || Date.now(),
|
||||
buffer: "",
|
||||
flushTimer: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
// Start periodic flush
|
||||
entry.flushTimer = setInterval(() => {
|
||||
flushBuffer(entry);
|
||||
}, FLUSH_INTERVAL);
|
||||
|
||||
activeStreams.set(sessionId, entry);
|
||||
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] Failed to start stream for ${sessionId}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered data to the write stream.
|
||||
* @param {object} entry - The stream entry
|
||||
*/
|
||||
function flushBuffer(entry) {
|
||||
if (!entry || entry.disabled || entry.buffer.length === 0) return;
|
||||
|
||||
try {
|
||||
const data = entry.buffer;
|
||||
entry.buffer = "";
|
||||
|
||||
if (entry.isHtml) {
|
||||
// For HTML format, write raw data during streaming; convert on close
|
||||
entry.writeStream.write(data);
|
||||
} else if (entry.format === "raw") {
|
||||
entry.writeStream.write(data);
|
||||
} else {
|
||||
// txt format: strip ANSI codes
|
||||
entry.writeStream.write(stripAnsi(data));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
entry.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to the session's log buffer.
|
||||
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
|
||||
* @param {string} sessionId
|
||||
* @param {string} dataChunk - Decoded terminal data string
|
||||
*/
|
||||
function appendData(sessionId, dataChunk) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry || entry.disabled) return;
|
||||
|
||||
entry.buffer += dataChunk;
|
||||
|
||||
// Immediate flush if buffer is large
|
||||
if (entry.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
flushBuffer(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the log stream for a session.
|
||||
* Flushes remaining data, closes the write stream, and finalizes the file.
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<string|null>} The final file path, or null if no stream was active
|
||||
*/
|
||||
async function stopStream(sessionId) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry) return null;
|
||||
activeStreams.delete(sessionId);
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
clearInterval(entry.flushTimer);
|
||||
entry.flushTimer = null;
|
||||
}
|
||||
|
||||
// Flush remaining buffer
|
||||
flushBuffer(entry);
|
||||
|
||||
// Close the write stream and wait for it to finish
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
|
||||
let finalPath = entry.filePath;
|
||||
|
||||
// For HTML format: read the temp raw file and convert to HTML
|
||||
if (entry.isHtml && !entry.disabled) {
|
||||
try {
|
||||
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
|
||||
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
|
||||
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
|
||||
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
|
||||
// Remove temp file
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
finalPath = htmlPath;
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
|
||||
// Keep the raw temp file as fallback
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has an active log stream.
|
||||
* @param {string} sessionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasStream(sessionId) {
|
||||
return activeStreams.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all active streams (called on app quit).
|
||||
*/
|
||||
async function cleanupAll() {
|
||||
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
|
||||
const ids = [...activeStreams.keys()];
|
||||
await Promise.allSettled(ids.map(id => stopStream(id)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startStream,
|
||||
appendData,
|
||||
stopStream,
|
||||
hasStream,
|
||||
cleanupAll,
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -977,6 +978,17 @@ async function startSSHSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname || '',
|
||||
hostname: options.hostname || '',
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || 'txt',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Data buffering for reduced IPC overhead
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
@@ -1009,12 +1021,16 @@ async function startSSHSession(event, options) {
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
bufferData(decoder.write(data));
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
bufferData(decoder.write(data));
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
|
||||
// Capture the real exit code from the remote process.
|
||||
@@ -1036,6 +1052,7 @@ async function startSSHSession(event, options) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
@@ -1086,6 +1103,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1100,6 +1118,7 @@ async function startSSHSession(event, options) {
|
||||
const err = new Error(`Connection timeout to ${options.hostname}`);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1112,6 +1131,7 @@ async function startSSHSession(event, options) {
|
||||
conn.once("close", () => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
@@ -11,6 +11,8 @@ const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
@@ -213,13 +215,26 @@ function startLocalSession(event, payload) {
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: "Local",
|
||||
hostname: "localhost",
|
||||
directory: payload.sessionLog.directory,
|
||||
format: payload.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
|
||||
proc.onExit((evt) => {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
@@ -390,6 +405,17 @@ async function startTelnetSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || hostname,
|
||||
hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
@@ -416,6 +442,7 @@ async function startTelnetSession(event, options) {
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -423,10 +450,11 @@ async function startTelnetSession(event, options) {
|
||||
socket.on('error', (err) => {
|
||||
console.error(`[Telnet] Socket error: ${err.message}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
|
||||
if (!connected) {
|
||||
reject(new Error(`Failed to connect: ${err.message}`));
|
||||
} else {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -440,6 +468,7 @@ async function startTelnetSession(event, options) {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -519,12 +548,25 @@ async function startMoshSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname,
|
||||
hostname: options.hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
@@ -607,6 +649,17 @@ async function startSerialSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || portPath,
|
||||
hostname: portPath,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const serialDecoder = new StringDecoder('latin1');
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
@@ -614,11 +667,13 @@ async function startSerialSession(event, options) {
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessions.delete(sessionId);
|
||||
@@ -626,6 +681,7 @@ async function startSerialSession(event, options) {
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessions.delete(sessionId);
|
||||
|
||||
@@ -8,6 +8,24 @@ const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
|
||||
/**
|
||||
* Safely ensure a local directory exists.
|
||||
* On Windows, `mkdir("E:\\", { recursive: true })` throws EPERM for drive roots.
|
||||
* We catch that and verify the directory already exists before re-throwing.
|
||||
*/
|
||||
async function ensureLocalDir(dir) {
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
} catch (err) {
|
||||
// If the directory already exists, ignore the error (covers EPERM on drive roots)
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
if (stat.isDirectory()) return;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
// dramatically improving throughput over sequential stream piping.
|
||||
@@ -430,14 +448,14 @@ async function startTransfer(event, payload, onProgress) {
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
|
||||
|
||||
} else if (sourceType === 'local' && targetType === 'local') {
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
|
||||
@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
@@ -849,6 +850,11 @@ if (!gotLock) {
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
sessionLogStreamManager.cleanupAll();
|
||||
} catch (err) {
|
||||
console.warn("Error during session log stream cleanup:", err);
|
||||
}
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
|
||||
7
global.d.ts
vendored
@@ -76,6 +76,8 @@ declare global {
|
||||
legacyAlgorithms?: boolean;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
@@ -143,6 +145,7 @@ declare global {
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -155,8 +158,9 @@ declare global {
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
@@ -165,6 +169,7 @@ declare global {
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
|
||||
@@ -13,8 +13,8 @@ export interface AcpAgentCallbacks {
|
||||
onTextDelta: (text: string) => void;
|
||||
onThinkingDelta: (text: string) => void;
|
||||
onThinkingDone: () => void;
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
|
||||
onToolResult: (toolCallId: string, result: string) => void;
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => void;
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => void;
|
||||
onStatus?: (message: string) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
@@ -50,12 +50,16 @@ interface StreamEvent {
|
||||
* Sends the prompt to the main process which runs streamText() with the ACP provider.
|
||||
* Stream events are forwarded back via IPC.
|
||||
*/
|
||||
export interface ImageAttachment {
|
||||
export interface FileAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use FileAttachment instead */
|
||||
export type ImageAttachment = FileAttachment;
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
@@ -167,16 +171,18 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
const input = (event.input as Record<string, unknown>) || {};
|
||||
callbacks.onToolCall(toolName, input);
|
||||
const toolCallId = (event.toolCallId as string) || undefined;
|
||||
callbacks.onToolCall(toolName, input, toolCallId);
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
const toolCallId = (event.toolCallId as string) || '';
|
||||
const toolName = (event.toolName as string) || undefined;
|
||||
const output = event.output ?? event.result;
|
||||
const result = typeof output === 'string'
|
||||
? output
|
||||
: JSON.stringify(output);
|
||||
callbacks.onToolResult(toolCallId, result);
|
||||
callbacks.onToolResult(toolCallId, result, toolName);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
/**
|
||||
* Classifies a raw error string into structured error info for display.
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const lower = error.toLowerCase();
|
||||
|
||||
// Network errors
|
||||
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
|
||||
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
|
||||
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
|
||||
}
|
||||
|
||||
// Auth errors
|
||||
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
|
||||
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
|
||||
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
|
||||
}
|
||||
|
||||
// Provider errors (5xx)
|
||||
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
|
||||
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
|
||||
}
|
||||
|
||||
// Model not found
|
||||
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
|
||||
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Command blocked
|
||||
if (lower.includes('blocked by safety')) {
|
||||
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -23,17 +23,23 @@ export interface ModelInfo {
|
||||
}
|
||||
|
||||
// Chat types
|
||||
export interface ChatMessageImage {
|
||||
export interface ChatMessageAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
filePath?: string; // original filesystem path (for ACP agents to read directly)
|
||||
}
|
||||
|
||||
/** @deprecated Use ChatMessageAttachment instead */
|
||||
export type ChatMessageImage = ChatMessageAttachment;
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
images?: ChatMessageImage[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
/** @deprecated Use attachments instead. Kept for backward compatibility with persisted sessions. */
|
||||
images?: ChatMessageAttachment[];
|
||||
thinking?: string;
|
||||
thinkingDurationMs?: number;
|
||||
toolCalls?: ToolCall[];
|
||||
@@ -144,7 +150,7 @@ export interface ExternalAgentConfig {
|
||||
env?: Record<string, string>;
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-code-acp', 'gemini --experimental-acp') */
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-agent-acp', 'gemini --experimental-acp') */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_clic
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR = 'netcatty_sftp_auto_open_sidebar_v1';
|
||||
|
||||
// Editor Settings
|
||||
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
|
||||
|
||||
351
package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
@@ -194,6 +195,29 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.2.76",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
|
||||
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.34.2",
|
||||
"@img/sharp-darwin-x64": "^0.34.2",
|
||||
"@img/sharp-linux-arm": "^0.34.2",
|
||||
"@img/sharp-linux-arm64": "^0.34.2",
|
||||
"@img/sharp-linux-x64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-arm64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-x64": "^0.34.2",
|
||||
"@img/sharp-win32-arm64": "^0.34.2",
|
||||
"@img/sharp-win32-x64": "^0.34.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
@@ -2657,6 +2681,310 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
@@ -6347,6 +6675,29 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@zed-industries/claude-agent-acp": {
|
||||
"version": "0.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/claude-agent-acp/-/claude-agent-acp-0.22.2.tgz",
|
||||
"integrity": "sha512-GLiKxy5MBNS9UoiE1XaM9EHVxlEcvk0sXSMCnyDp9JNAQliynt0axZrhptTl5AWe6PXGjVh5hMFdPp+yulw2uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.76",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/claude-agent-acp/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
|
||||
"integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/codex-acp": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.10.0.tgz",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 MiB |
BIN
screenshots/gifs/custom-highlight.mp4
Normal file
|
Before Width: | Height: | Size: 4.2 MiB |
BIN
screenshots/gifs/custom-themes.mp4
Normal file
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/gifs/drag-file-upload.mp4
Normal file
|
Before Width: | Height: | Size: 2.7 MiB |
BIN
screenshots/gifs/dual-terminal--split-manage.mp4
Normal file
|
Before Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/gifs/gird-list-tre-views.mp4
Normal file
BIN
screenshots/gifs/main-window-light.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.1 MiB |
BIN
screenshots/gifs/sftpview-with-drag-and-built-in-editor.mp4
Normal file
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |