Compare commits

...

29 Commits

Author SHA1 Message Date
bincxz
be7aa4ae52 fix: resolve eslint warnings in App.tsx and VaultView.tsx
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:57:19 +08:00
陈大猫
f4872099bd fix: real-time session logging via main process streams (#403)
* fix: implement real-time session logging via main process streams

Fixes #394. Session logs previously only captured ~55 lines (the
xterm serialize buffer) and were written only on session close. This
change intercepts terminal data in the main process and writes it to
a file stream in real-time, capturing the complete session output.

- Add sessionLogStreamManager.cjs: manages per-session write streams
  with 500ms/64KB flush, supports txt/raw/html formats
- sshBridge: start stream on shell open, append on data, stop on close
- terminalBridge: same for local, telnet, mosh, serial sessions
- Thread sessionLog config from renderer settings through IPC options
- Skip old renderer-side auto-save when streaming is active
- Cleanup all streams on app quit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove stale renderer-side auto-save and async HTML finalization

- Remove dead renderer-side auto-save code (main process handles it)
- Make stopStream async, await writeStream finish before HTML conversion
- Use fs.promises for HTML read/write/unlink

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:44:54 +08:00
陈大猫
4e2089d7e2 feat: add option to auto-open sidebar on host connect (#401)
* feat: add option to auto-open sidebar on host connect

Closes #396

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only auto-open SFTP sidebar for SSH/Mosh connections

Use allowlist (ssh, mosh) instead of blocklist so telnet and other
non-SSH protocols don't trigger SFTP sidebar which would fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: support auto-open SFTP for Quick Connect / temporary sessions

Build a minimal Host from session data when hostId is not in the vault,
so Quick Connect sessions also trigger auto-open SFTP sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync SFTP auto-open sidebar setting across windows via IPC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip local terminals and preserve username for temp sessions

- Don't fallback protocol to 'ssh' so local terminals are excluded
- Include session.username in synthesized Host for Quick Connect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:12:53 +08:00
陈大猫
5f28320c57 fix: suppress known_hosts toast on auto-scan at startup (#402)
* fix: suppress known_hosts toast on auto-scan at startup

The auto-scan on first mount now runs silently — no toasts for missing
known_hosts file, no entries, or no new hosts. Users still see toasts
when manually clicking "Scan System".

Closes #398

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wrap onClick handlers to avoid passing event as silent flag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:44:08 +08:00
陈大猫
4e26852482 feat: support multimodal attachments in AI chat (#397)
* feat: support multimodal attachments (images, PDFs, files) in AI chat

Previously uploaded images were displayed in the UI but never sent to
the AI model, and non-image files (PDF, text) were silently rejected.

- Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/*
- Rename ChatMessageImage → ChatMessageAttachment with filePath support
- Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent
- Fix ACP agent path: images inline, non-image files via local path hint
  so ACP agents (Claude Code, etc.) read them with native file access
- Use Electron webUtils.getPathForFile() for reliable file path capture
- Compact user message bubble padding

Closes #294 (AI file upload issues)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show real tool names in AI chat instead of ACP wrapper names

- Unwrap ACP dynamic tool calls in serializeStreamChunk to extract
  real tool name, args, and toolCallId from chunk.input
- Simplify MCP tool name prefixes (mcp__server__tool → tool)
- Pass toolCallId from ACP tool-call events to match tool results
- Prevent onToolResult from overwriting correct names with wrapper name
- Build toolCallNames map in ChatMessageList for tool result display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: backward-compatible fallback for legacy `images` field in chat messages

Persisted sessions may still have `images` instead of `attachments`.
Add `?? m.images` fallback in SDK message builder and renderer so
historical image attachments are not silently dropped after upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: broaden file type support and handle pasted files without path

- Accept all file types except video/audio (instead of allowlist)
  so .json, .yaml, .sh, etc. are not silently rejected
- For ACP agents, save pasted/virtual files (no filePath) to temp
  directory so the agent can still read them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use managed temp dir for pasted ACP attachments

Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path
so pasted file attachments are tracked by the app's cleanup system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:45:50 +08:00
yuzifu
c4fb19cafb update supported distros (#395) 2026-03-19 09:31:22 +08:00
bincxz
09e6526142 Remove GIFs, align zh-CN and ja-JP READMEs with main
- Delete all GIF files (replaced by mp4/user-attachments)
- Update demo sections to use GitHub video attachments
- Add contributor avatars via contrib.rocks
- Add Star History chart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:59:42 +08:00
陈大猫
7ce110c3fb Update asset links in README.md
Updated asset links for various features in the README.
2026-03-19 01:52:27 +08:00
bincxz
667ee18ed3 Compress demo mp4 files (~52MB → ~2.5MB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:50:23 +08:00
陈大猫
f969b1b73d Add links for SFTP and drag file upload sections
Updated README to include links for SFTP and drag file upload.
2026-03-19 01:43:47 +08:00
陈大猫
58a4bf892a Update video references in README.md
Replaced video tags with links to video assets for better accessibility.
2026-03-19 01:39:38 +08:00
bincxz
5052a8231f Improve README: mp4 demos, contributor avatars, star history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:34:00 +08:00
bincxz
13c9cf16fd Update screenshots and add demo GIFs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:26:16 +08:00
陈大猫
63558b5301 Remove HTTP localhost-only restriction for AI requests (#393)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Remove the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.

Security measures:
- Only providers explicitly configured with http:// are allowed over HTTP
- HTTPS-configured providers cannot be silently downgraded
- Temporary HTTP permissions expire after 30s TTL
- Non-http/https schemes are explicitly rejected
- webSearchApiHost entries preserved from accidental expiry

Fixes #392
2026-03-18 19:57:47 +08:00
陈大猫
c2b4d43531 Merge pull request #391 from binaricat/fix/sftp-download-windows-drive-root 2026-03-18 16:11:10 +08:00
bincxz
4d5c0eed69 Fix SFTP download failing on Windows drive root paths
On Windows, `fs.promises.mkdir("E:\", { recursive: true })` throws
EPERM for drive root directories. When users save SFTP downloads to a
drive root (e.g. E:\file.txt), `path.dirname` returns "E:\" and the
subsequent mkdir fails. Fix by catching the error and verifying the
directory already exists before re-throwing.

Fixes #390

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:06:23 +08:00
bincxz
3ad710e5da Fix AI error message wrapping
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-18 13:38:30 +08:00
陈大猫
d2e5a26317 Merge pull request #374 from yuzifu/fix-host-count-in-tree-view
Fix host count in tree view
2026-03-18 13:30:42 +08:00
陈大猫
4f1eb4a8a9 Merge pull request #389 from binaricat/codex/show-raw-ai-errors
Show raw AI errors instead of inferred causes
2026-03-18 13:26:41 +08:00
bincxz
e35bb708a2 Show raw AI errors instead of inferred causes 2026-03-18 13:00:27 +08:00
陈大猫
cd2631428e Fix AI scope leaking across tab switches (#388)
* Fix AI scope leaking across tab switches

* Keep AI executor context live across resumes
2026-03-18 11:56:28 +08:00
yuzifu
09af399543 fix: import import certificate icon size too small (#387)
fix icon small when dropdown item text is too long

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-18 10:07:07 +08:00
yuzifu
a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +08:00
yuzifu
1d4ec7afb9 Merge remote-tracking branch 'origin/fix-host-count-in-tree-view' into fix-host-count-in-tree-view 2026-03-17 17:25:00 +08:00
yuzifu
a1899951e0 fix: show hosts count(update)
Avoid recalculating the number of hosts during re-rendering
2026-03-17 17:24:16 +08:00
bincxz
d84668aa0f perf: memoize subtree host count to avoid repeated traversals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:41 +08:00
yuzifu
68d0f4574c update show hosts count in tree view 2026-03-17 16:40:45 +08:00
yuzifu
bedf59bb48 update show host count in tree view 2026-03-17 10:17:57 +08:00
yuzifu
793ea94078 fix: show host count in tree view 2026-03-17 09:16:01 +08:00
59 changed files with 1100 additions and 347 deletions

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ coverage
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)

30
App.tsx
View File

@@ -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 */}

View File

@@ -59,6 +59,8 @@
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -110,37 +112,37 @@
<a name="デモ"></a>
# デモ
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
動画で機能をさっと確認できます(素材は `screenshots/gifs/`
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -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.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
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.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
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.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
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.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
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.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -59,6 +59,8 @@
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -111,37 +113,37 @@
<a name="演示"></a>
# 演示
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -745,6 +745,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}',

View File

@@ -1060,6 +1060,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}',

View 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 };
}

View File

@@ -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 };
}

View File

@@ -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,
]),
};

View File

@@ -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}

View File

@@ -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 [];

View File

@@ -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"

View File

@@ -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

View File

@@ -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;

View File

@@ -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 &&

View File

@@ -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>

View File

@@ -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,
)}

View File

@@ -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 = '';
}
}}

View File

@@ -6,7 +6,7 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle } from 'lucide-react';
import { AlertCircle, FileText } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
@@ -36,6 +36,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">
@@ -58,7 +68,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 +93,26 @@ 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"
/>
) : (
<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 +159,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>
)}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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')} />

View File

@@ -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

View File

@@ -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 {

View File

@@ -197,8 +197,9 @@ export interface SyncPayload {
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
};
// Sync metadata
syncedAt: number; // When this payload was created
}

View File

@@ -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));
}
// ---------------------------------------------------------------------------

View File

@@ -154,13 +154,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",

View File

@@ -486,6 +486,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 +499,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 +543,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 +594,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 +603,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 +622,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 +708,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 +740,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" };
}
@@ -1802,20 +1837,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;
}

View File

@@ -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;
}

View 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,
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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
View File

@@ -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;

View File

@@ -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': {

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB