Compare commits

...

22 Commits

Author SHA1 Message Date
bincxz
1f0d3d8274 Handle cross-device mosh bundle moves
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-05-01 17:10:13 +08:00
bincxz
d8c62a55f5 Fix Windows mosh bundle extraction 2026-05-01 16:54:57 +08:00
陈大猫
1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
bincxz
de7057183c Increase AI code block top spacing 2026-05-01 13:48:42 +08:00
bincxz
dd910cc53d Tighten AI code block spacing 2026-05-01 13:43:06 +08:00
陈大猫
8ccefc821c [codex] Use dedicated mosh binary repository (#881)
* Use dedicated mosh binary repository

* Require bundled mosh client

* Auto-fill saved password for mosh SSH handshake

* Harden bundled mosh binary flow
2026-05-01 11:54:10 +08:00
陈大猫
863397fc7d Fix DeepSeek reasoning replay for tool loops (#882)
* Fix OpenAI-compatible reasoning replay for tool loops

* Fix reasoning continuation replay
2026-05-01 11:45:47 +08:00
陈大猫
6a39ed05a9 [codex] Tighten AI chat spacing (#883)
* Tighten AI chat spacing

* Scope AI table spacing styles
2026-05-01 11:33:07 +08:00
陈大猫
470d9b5aae [codex] Improve ACP agent error diagnostics (#880) 2026-05-01 08:00:50 +08:00
陈大猫
20694a47dd Fix Codex ACP model picker (#879) 2026-05-01 08:00:05 +08:00
陈大猫
d86c5ed05a [codex] Remove mosh client path setting (#878)
* fix(terminal): remove mosh client path setting

* fix(terminal): remove stale mosh detection bridge
2026-04-30 17:54:35 +08:00
陈大猫
fdaaaf62d8 [codex] Preserve provider reasoning context (#877)
* fix(ai): preserve provider reasoning context

* fix(ai): harden provider continuation replay
2026-04-30 17:08:19 +08:00
秋秋
2ceea46b50 feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval (#869)
* feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval

* fix ssh cwd detection review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 15:27:45 +08:00
Eric Chan
5a1d6931a5 Fix Tab completion preferring history over local files (#867)
* Fix spec-aware path completion priority

Use resolved Fig spec args when deciding when filesystem suggestions should outrank command history. Add a regression test covering a spec-driven file argument command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix generator-only spec path completion

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:42:01 +08:00
yuzifu
fb97e242ee feat: add SFTP upload conflict handling (#874)
* feat: add SFTP upload conflict handling
Add conflict resolution for SFTP uploads so files and folders can be stopped, skipped, replaced, duplicated, or merged depending on the target state. Support batch uploads with Apply to All behavior, route external upload conflicts through the shared SFTP conflict dialog, and add the bridge operations needed to stat and delete existing upload targets.

* fix review issue

* Fix SFTP conflict cancellation cleanup

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:22:00 +08:00
YumeSaku
68040ebdd7 fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators (#871)
* fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators

oh-my-posh and similar themed prompts end with PUA codepoints (e.g. U+F105
chevron, U+E0B0 powerline arrow) that aren't in the hardcoded PROMPT_CHARS
set, so findPromptBoundary returned -1 and both ghost-text and popup
autocomplete went silent. Treat any Private Use Area char (U+E000-U+F8FF)
followed by a space as a candidate prompt terminator — real shell commands
essentially never contain PUA codepoints, so this is high-confidence.

* Fix Powerline glyph prompt splitting

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:57:07 +08:00
Blossom
cca6dac543 fix(sftp): use custom tooltips in transfer queue (#872)
* fix(sftp): replace transfer queue native tooltips

* Fix SFTP transfer tooltip regressions

* Improve SFTP transfer tooltip accessibility

* Cover SFTP cancel tooltip label

---------

Co-authored-by: Mack Ding <mackding@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:23:51 +08:00
陈大猫
d86b720748 Run CI on every push/PR; gate release on strict v tags (#868)
* Run CI on every push/PR; gate release on strict v<X>.<Y>.<Z> tags

The build-packages workflow used to trigger only on `push: tags: v*`,
so branches and PRs never built and the only way to test the matrix
was to push a tag — which also auto-published a GitHub Release. That
made it impossible to verify a CI change without either skipping
testing or shipping a junk release.

Restructure the triggers:

- `push: branches: ['**']` + `pull_request` so any push or PR runs
  the build matrix and uploads workflow artifacts.
- `push: tags` accepts only strict semver: `v<MAJOR>.<MINOR>.<PATCH>`
  with an optional pre-release suffix like `v1.2.3-rc.1`. Loose tags
  (`v-test`, `vNEXT`, `v1.0`) no longer match.
- The release job's `if:` enforces the same rule independently — even
  if someone re-broadens the trigger later, branches and PRs can't
  publish a release.
- `Set version` produces semver-compliant `0.0.0-sha.<short>` for
  non-tag runs so `npm pkg set` / electron-builder don't choke on a
  bare commit SHA like `abc1234`.
- Add a concurrency group that cancels superseded branch/PR builds
  to save runner minutes; tag builds use a unique group so releases
  never get cancelled by a follow-up commit.

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

* Apply strict-semver Set-version step to Linux jobs too

The previous commit only patched the matrix job's Set version step
(macOS/Windows) because the Linux legs had a slightly different
template (no comments). The Linux Set version step kept setting
package.json's version to a bare 7-char commit SHA like "812f296",
which electron-builder rejects with `Invalid version: "812f296"`
during normalizePackageData.

Replicate the same strict regex + 0.0.0-sha.<short> fallback in both
Linux jobs so non-tag runs produce a valid semver across the matrix.

Reproduced from build-linux-x64 logs of the run on 112bf3a1:
  Setting version to 812f296
  ⨯ Invalid version: "812f296"  failedTask=build

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

* Fix build workflow trigger review issues

* Address build workflow review findings

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:22:50 +08:00
陈大猫
aa192c66c3 Wire bundled mosh release flow
* Wire bundled mosh release flow

* Fix bundled mosh release flow review findings
2026-04-30 09:28:08 +08:00
陈大猫
7dd25a55bb Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline

Add a GitHub Actions workflow that builds a static, distro-portable
mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64)
from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary
sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so
scripts/fetch-mosh-binaries.cjs can verify and pull the right binary
into resources/mosh/<platform-arch>/ during npm run pack.

electron-builder.config.cjs gains a moshExtraResources() helper that
adds the binary to extraResources only when present on disk, keeping
local dev packages working without bundled mosh.

terminalBridge.cjs now exports bundledMoshClient() and prefers the
bundled static client over whatever the system mosh wrapper would
resolve via PATH (via the MOSH_CLIENT env var). The Windows branch
throws a clear error pointing at Settings instead of silently falling
back to a literal "mosh.exe" string when no wrapper is installed.

This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal
Windows binary with an in-CI Cygwin static build and adds a Node-side
mosh-server bootstrap so Mosh works out-of-the-box on Windows.

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

* Phase 2: Node-side Mosh handshake (no Perl wrapper required)

Reimplement what the upstream Mosh Perl wrapper does in pure Node:
spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream
for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally
with MOSH_KEY in the environment.

The new electron/bridges/moshHandshake.cjs module exposes the parser,
sniffer, and command builders as pure functions so they can be unit
tested without spawning real ssh. terminalBridge.startMoshSession now
prefers this path whenever a bare mosh-client (bundled, explicit, or
system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere
else) are both detectable. The legacy path through the system mosh
Perl wrapper is preserved as a fallback so users with custom mosh
setups don't regress.

Auth is delegated to system ssh, so keys, agent, ssh_config, and
known_hosts all keep working. Password / 2FA need a controlling TTY
which the bootstrap doesn't provide; affected users keep the legacy
wrapper path until interactive UI lands.

Tests:
- moshHandshake.test.cjs (20 tests) — parser corner cases, command
  builders, sniffer split-chunk handling, ring-buffer trim, exec
  resolver
- terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path
  basename gating

317 → 341 passing tests; lint clean.

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

* Phase 3: in-CI Cygwin Windows build + visible PTY handshake

Phase 3a — in-CI Cygwin Windows build
- scripts/build-mosh/build-windows.sh builds mosh-client.exe from
  upstream mobile-shell/mosh source inside Cygwin, then walks the
  cygcheck import graph to bundle every required Cygwin DLL
  (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a
  tar.gz alongside the exe.
- The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned
  fetch job for a real Cygwin build (windows-latest + cygwin-install-
  action). fetch-windows.sh is preserved as an emergency fallback but
  no longer wired into the matrix.
- fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/
  win32-x64/ so mosh-client.exe sits next to its DLLs.
- mosh-extra-resources.cjs ships the entire win32-x64/ dir
  (exe + DLL bundle) into Resources/mosh/, so the packaged installer
  runs on a stock Windows host with no Cygwin install.

Phase 3b — visible PTY handshake (password / 2FA prompts)
- terminalBridge.startMoshSession now spawns ssh inside node-pty so
  the user sees and can answer password / 2FA / known-hosts prompts
  in their terminal. When `MOSH CONNECT` is sniffed from the byte
  stream, session.proc is atomically swapped from the ssh PTY to a
  freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is
  redacted from the visible output.
- writeToSession / resizeSession read session.proc lazily, so input
  arriving after the swap goes to mosh-client without extra wiring.
- The ZMODEM sentry is recreated for the new proc since its
  writeToRemote closure captured the previous handle.
- Removes the earlier non-PTY child_process.spawn handshake — the
  PTY-based one supersedes it.

Phase 3c — win32-arm64 deferred
- Cygwin's arm64 port has no stable cygwin1.dll release yet, so we
  do not attempt an arm64 Windows build. arm64 Windows installs fall
  through to the legacy `mosh` wrapper path that the bridge already
  handles. Documented in the workflow.

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

* Allow branch/PR pushes to test the mosh-binaries workflow

Mirrors the build-packages workflow change in #868: any push or PR
that touches the mosh build pipeline triggers the matrix (artifacts
only, no release), while only `mosh-bin-*` tag pushes (or an
explicit workflow_dispatch with release_tag) publish a release.

`paths` filter keeps unrelated commits from running this expensive
workflow (~30min for the Cygwin leg). Concurrency group cancels
superseded branch/PR builds; tag builds use a unique group so a
follow-up commit can't kill an in-progress release.

Release job's `if:` enforces the same rule independently — even if
the trigger gets re-broadened, branches/PRs can't leak a release.

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

* Fix mosh binary workflow runners

* Fix Windows mosh workflow invocation

* Keep shell scripts LF in workflow checkouts

* Trigger mosh workflow on attributes changes

* Fix mosh build tool dependencies

* Fix Linux mosh static build

* Fix macOS mosh build tool lookup

* Skip macOS ncurses terminfo install

* Fix mosh PR review findings

* Allow Linux system mosh dependencies

* Fix Windows mosh DLL bundling

* Limit bundled Windows mosh DLLs

* Honor configured PATH for mosh handshake

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:25:57 +08:00
陈大猫
e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫
4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
109 changed files with 9996 additions and 1148 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -56,8 +56,7 @@ const files = {
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
x64: `Netcatty-${version}-win-x64.exe`
},
linux: {
appimage: {
@@ -77,8 +76,7 @@ const files = {
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
@@ -99,7 +97,7 @@ const content = `
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;

View File

@@ -0,0 +1,262 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
# source. Cygwin's POSIX runtime can't be fully statically linked, so
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
# path is preserved as `fetch-windows.sh` for emergency fallback.
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Cygwin
uses: cygwin/cygwin-install-action@v5
with:
add-to-path: false
# Keep package signature checks, but avoid the setup.exe hash
# fetch path that currently fails on windows-latest runners.
check-hash: false
packages: >
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
libncursesw-devel zlib-devel protobuf-compiler
- name: Build mosh-client.exe (win32-x64)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$cygwinBin = "C:\cygwin\bin"
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
$script = @'
set -euo pipefail
cd "__WORKSPACE__"
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
export ARCH=x64
export OUT_DIR="__WORKSPACE__/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/build-windows.sh
'@
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
# release for aarch64 as of this commit), so we don't attempt an
# arm64 mosh build. arm64 Windows installs fall through to the
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
# When upstream Cygwin ships a stable arm64 build, drop the same
# cygwin-install-action job below with `platform: arm64`.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi

View File

@@ -1,5 +1,23 @@
name: build-packages
# Trigger philosophy
# - Any push to any branch + any PR -> run the build matrix so CI is
# always testable. Same-repo PR runs own package validation; matching
# branch push runs become a lightweight mirror only after a current
# open PR run for the same commit is visible. If lookup is slow or
# unavailable, the push run falls back to the full matrix. Artifacts
# upload as workflow artifacts only; *no* GitHub Release is published.
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
# `v1.0` no longer auto-publish.
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
# `publish_release` only publishes when the selected ref is also a
# strict version tag.
#
# The release job validates the exact same rule before publishing, so
# adding branches/PRs above is safe; accidental tag-like branch names
# won't leak a release.
on:
workflow_dispatch:
inputs:
@@ -7,13 +25,179 @@ on:
description: "Publish GitHub Release after build"
type: boolean
default: false
mosh_bin_release:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
push:
branches:
- "**"
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
pull_request:
# A newer run for the same push branch or PR cancels older in-progress
# work. Push and PR events stay in separate groups so deduped push runs
# can mirror PR results cleanly instead of leaving cancelled checks on
# the PR. Publishing tag runs share a release group across push and
# manual dispatch; non-publishing manual tag runs use their own group.
concurrency:
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
actions: read
contents: read
pull-requests: read
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
dedupe:
name: dedupe push run
runs-on: ubuntu-latest
outputs:
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
steps:
- name: Detect duplicate heavy CI
id: detect
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.ref_name }}
HEAD_SHA: ${{ github.sha }}
run: |
skip_heavy_ci=false
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
pr_count=0
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
-f state=open \
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
-F per_page=1 \
--jq 'length')"; then
echo "::warning::Could not check open PRs; running full push CI."
pr_count=0
fi
pr_run_id=""
if [[ "$pr_count" != "0" ]]; then
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
for attempt in {1..18}; do
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=pull_request \
-f "branch=${HEAD_REF}" \
-f "head_sha=${HEAD_SHA}" \
-F per_page=20 \
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
echo "::warning::Could not check PR workflow runs; running full push CI."
pr_run_id=""
break
fi
if [[ -n "$pr_run_id" ]]; then
skip_heavy_ci=true
break
fi
if [[ "$attempt" == "18" ]]; then
break
fi
sleep 10
done
fi
if [[ -n "$pr_run_id" ]]; then
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
echo "heavy_ci_pr_run_id=${pr_run_id}"
fi
fi
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
echo "skip_heavy_ci=${skip_heavy_ci}"
dedupe-result:
name: dedupe result
needs: dedupe
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
runs-on: ubuntu-latest
steps:
- name: Mirror PR build result
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
run: |
if [[ -z "$PR_RUN_ID" ]]; then
echo "::error::No PR workflow run was selected for dedupe."
exit 1
fi
for attempt in {1..360}; do
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
sleep 30
continue
fi
status="${result%%|*}"
conclusion="${result#*|}"
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
exit 0
fi
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
exit 1
fi
sleep 30
done
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
exit 1
resolve-mosh:
name: resolve bundled mosh-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled mosh-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-mosh-bin-release.cjs
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::MOSH_BIN_RELEASE was not resolved."
exit 1
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: build-${{ matrix.name }}
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,13 +208,28 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -46,27 +245,40 @@ jobs:
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# npm ci only installs optional deps for the host platform.
# macOS packages still cover both arm64 and x64, so we need
# codex-acp for both architectures there.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
fi
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:mosh -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
# tags / branches / PRs fall through to a semver-pre-release
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
# accept it. Non-semver versions (e.g. bare "abc1234") cause
# downstream tooling to error or pick weird codepaths.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -105,9 +317,15 @@ jobs:
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -115,6 +333,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -130,10 +359,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -143,6 +375,10 @@ jobs:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Build package
env:
npm_config_arch: x64
@@ -171,11 +407,17 @@ jobs:
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -183,6 +425,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
@@ -201,10 +454,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -214,6 +470,10 @@ jobs:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64
@@ -242,7 +502,12 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
# Manual workflow_dispatch can publish only when it is run from one
# of those tags. PRs and branch pushes skip this job.
if: |
startsWith(github.ref, 'refs/tags/v')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
@@ -250,6 +515,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
exit 1
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -318,6 +591,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/*.dmg
artifacts/*.zip

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: test
on:
pull_request:
push:
branches:
- "**"
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test

10
.gitignore vendored
View File

@@ -63,3 +63,13 @@ Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries (and on Windows the Cygwin DLL
# bundle that ships alongside mosh-client.exe) are pulled from the
# dedicated mosh binary repository, never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll

44
App.tsx
View File

@@ -17,14 +17,14 @@ import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -58,7 +58,7 @@ import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
import { TextEditorTabView } from './components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
import { editorSftpWrite } from './application/state/editorSftpBridge';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from './application/state/editorTabSave';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -207,6 +207,8 @@ function App({ settings }: { settings: SettingsState }) {
theme,
setTheme,
resolvedTheme,
accentMode,
customAccent,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
@@ -366,14 +368,19 @@ function App({ settings }: { settings: SettingsState }) {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
// Workspace
@@ -403,7 +410,7 @@ function App({ settings }: { settings: SettingsState }) {
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
@@ -441,7 +448,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildSyncPayload(
return buildLocalVaultPayload(
{
hosts,
keys,
@@ -557,7 +564,6 @@ function App({ settings }: { settings: SettingsState }) {
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
startupReady: startupSyncSafetyReady,
@@ -1763,6 +1769,7 @@ function App({ settings }: { settings: SettingsState }) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
@@ -1785,16 +1792,15 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
if (choice === 'save') {
try {
editorTabStore.setSavingState(id, 'saving');
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
closeEditorAndActivateNeighbor(id);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Save failed';
editorTabStore.setSavingState(id, 'error', msg);
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return;
closeEditorAndActivateNeighbor(id);
}
};
@@ -1915,6 +1921,8 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}

View File

@@ -378,18 +378,6 @@ const en: Messages = {
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.mosh.client': 'Mosh client path',
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
'settings.terminal.mosh.detect': 'Detect',
'settings.terminal.mosh.browse': 'Browse…',
'settings.terminal.mosh.autoDetected': 'Auto-detected',
'settings.terminal.mosh.detected': 'Detected at',
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
@@ -790,6 +778,9 @@ const en: Messages = {
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
@@ -856,8 +847,11 @@ const en: Messages = {
'sftp.conflict.size': 'Size:',
'sftp.conflict.modified': 'Modified:',
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
'sftp.conflict.action.stop': 'Stop',
'sftp.conflict.action.skip': 'Skip',
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.duplicate': 'Duplicate',
'sftp.conflict.action.merge': 'Merge',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases

View File

@@ -562,6 +562,9 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
@@ -1214,8 +1217,11 @@ const zhCN: Messages = {
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改时间:',
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳过',
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.duplicate': '创建副本',
'sftp.conflict.action.merge': '合并',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
@@ -1462,18 +1468,6 @@ const zhCN: Messages = {
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',
'settings.terminal.mosh.client': 'Mosh 客户端路径',
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local。',
'settings.terminal.mosh.client.placeholder': '自动探测',
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
'settings.terminal.mosh.detect': '探测',
'settings.terminal.mosh.browse': '浏览…',
'settings.terminal.mosh.autoDetected': '自动检测到',
'settings.terminal.mosh.detected': '已找到',
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
import { createEditorTabSaveService } from "./editorTabSave.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/tmp/file.txt",
fileName: "file.txt",
languageId: "plaintext",
content: "v1",
baselineContent: "old",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("editor tab save service joins duplicate saves for the same content", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const pending = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await pending.promise;
},
});
const first = service.saveTab("edt_1");
const second = service.saveTab("edt_1", "v1");
assert.deepEqual(writes, ["v1"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(writes, ["v1"]);
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
assert.equal(store.getTab("edt_1")?.savingState, "idle");
});
test("editor tab save service queues newer tab content after an in-flight save", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const firstSave = deferred();
const secondSave = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = service.saveTab("edt_1");
store.updateContent("edt_1", "v2", null);
const second = service.saveTab("edt_1");
assert.deepEqual(writes, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(writes, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
assert.equal(store.getTab("edt_1")?.content, "v2");
});

View File

@@ -0,0 +1,72 @@
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from "./textEditorSaveCoordinator";
interface EditorTabSaveServiceDeps {
store: EditorTabStore;
write: EditorSftpWrite;
}
export interface EditorTabSaveService {
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
releaseTab(id: EditorTabId): void;
}
const formatSaveError = (error: unknown): string =>
error instanceof Error ? error.message : "Save failed";
export const createEditorTabSaveService = ({
store,
write,
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
const existing = coordinators.get(id);
if (existing) return existing;
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
const tab = store.getTab(id);
if (!tab) throw new Error("Editor tab closed before save completed");
await write(tab.sessionId, tab.hostId, tab.remotePath, content);
},
onSaveStart: () => {
store.setSavingState(id, "saving");
},
onSaveSuccess: (content) => {
store.markSaved(id, content);
},
onSaveError: (error) => {
store.setSavingState(id, "error", formatSaveError(error));
},
});
coordinators.set(id, coordinator);
return coordinator;
};
return {
saveTab: async (id, contentOverride) => {
const tab = store.getTab(id);
if (!tab) return false;
return getCoordinator(id).save(contentOverride ?? tab.content);
},
releaseTab: (id) => {
const coordinator = coordinators.get(id);
coordinator?.reset();
coordinators.delete(id);
},
};
};
const editorTabSaveService = createEditorTabSaveService({
store: editorTabStore,
write: editorSftpWrite,
});
export const saveEditorTab = editorTabSaveService.saveTab;
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;

View File

@@ -196,3 +196,24 @@ test("confirmCloseBySession invokes save callback for 'save' choice and only clo
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});

View File

@@ -167,17 +167,23 @@ export class EditorTabStore {
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") { this.close(tab.id); continue; }
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
@@ -186,6 +192,7 @@ export class EditorTabStore {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import React, { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -63,6 +63,8 @@ interface SftpExternalOperationsResult {
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
uploadConflicts: FileConflict[];
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
}
export const useSftpExternalOperations = (
@@ -88,6 +90,11 @@ export const useSftpExternalOperations = (
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef(new Map<string, {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
}>());
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
@@ -496,18 +503,99 @@ export const useSftpExternalOperations = (
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
const resolver = uploadConflictResolversRef.current.get(conflictId);
if (!resolver) return;
uploadConflictResolversRef.current.delete(conflictId);
if (conflict && applyToAll) {
resolver.setDefault(action);
}
resolver.resolve(action);
}, [uploadConflicts]);
const cancelPendingUploadConflicts = useCallback(() => {
const resolvers = Array.from(uploadConflictResolversRef.current.values());
if (resolvers.length === 0) return;
uploadConflictResolversRef.current.clear();
setUploadConflicts([]);
for (const resolver of resolvers) {
resolver.resolve("stop");
}
}, []);
const createUploadConflictResolver = useCallback(() => {
const conflictDefaults = new Map<string, FileConflictAction>();
return async (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
const fileConflict: FileConflict = {
transferId: conflictId,
fileName: conflict.fileName,
sourcePath: "local",
targetPath: conflict.targetPath,
isDirectory: conflict.isDirectory,
existingType: conflict.existingType,
applyToAllCount: conflict.applyToAllCount,
existingSize: conflict.existingSize,
newSize: conflict.newSize,
existingModified: conflict.existingModified,
newModified: conflict.newModified,
};
setUploadConflicts((prev) => [...prev, fileConflict]);
return new Promise<FileConflictAction>((resolve) => {
uploadConflictResolversRef.current.set(conflictId, {
resolve,
setDefault: (action) => {
conflictDefaults.set(conflictType, action);
},
});
});
};
}, []);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
deleteLocalFile: bridge?.deleteLocalFile,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
statSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
@@ -596,6 +684,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller
);
@@ -624,6 +713,7 @@ export const useSftpExternalOperations = (
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
@@ -680,6 +770,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
@@ -707,6 +798,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
@@ -716,11 +808,14 @@ export const useSftpExternalOperations = (
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
let cancelPromise: Promise<void> | undefined;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
cancelPromise = controller.cancel();
}
}, []);
cancelPendingUploadConflicts();
await cancelPromise;
}, [cancelPendingUploadConflicts]);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -744,5 +839,7 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
};
};

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
FileConflictAction,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
@@ -61,7 +62,7 @@ interface UseSftpTransfersResult {
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
}
interface TransferResult {
@@ -96,6 +97,7 @@ export const useSftpTransfers = ({
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
const conflictDefaultsRef = useRef<Map<string, FileConflictAction>>(new Map());
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
@@ -122,6 +124,196 @@ export const useSftpTransfers = ({
[],
);
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
[],
);
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: fileName, ext: "" };
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return { baseName: fileName, ext: "" };
return {
baseName: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}, []);
const statTargetPath = useCallback(
async (
targetPane: SftpPane,
targetSftpId: string | null,
targetPath: string,
targetEncoding: SftpFilenameEncoding,
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
},
[splitNameForDuplicate, statTargetPath],
);
const completeCancelledTask = useCallback(
async (task: TransferTask) => {
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
},
[],
);
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
const idsToCancel = new Set<string>();
const currentTransfers = transfersRef.current;
for (const transferId of transferIds) {
idsToCancel.add(transferId);
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.add(childId);
cancelledTasksRef.current.add(childId);
}
}
for (const transfer of currentTransfers) {
if (
transfer.parentTaskId === transferId &&
(transfer.status === "transferring" || transfer.status === "pending")
) {
idsToCancel.add(transfer.id);
cancelledTasksRef.current.add(transfer.id);
}
}
}
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
if (!cancelTransferAtBackend) return;
await Promise.all(
Array.from(idsToCancel).map((id) =>
cancelTransferAtBackend(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}, []);
const markBatchStopped = useCallback(
async (task: TransferTask) => {
const batchId = task.batchId;
const affected = transfersRef.current.filter((candidate) =>
candidate.id === task.id ||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
);
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
const affectedIds = new Set(affected.map((candidate) => candidate.id));
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
setTransfers((prev) => {
for (const candidate of prev) {
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
cancelledTasksRef.current.add(candidate.id);
}
}
return prev
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
.map((candidate) =>
affectedIds.has(candidate.id)
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
: candidate,
);
});
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
for (const candidate of affected) {
await completeCancelledTask(candidate);
}
},
[cancelBackendTransfers, completeCancelledTask],
);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
},
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
@@ -557,6 +749,10 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
}
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
@@ -676,7 +872,7 @@ export const useSftpTransfers = ({
// Run size discovery and conflict check in parallel
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
if (task.skipConflictCheck || task.isDirectory || !targetPane.connection) return null;
if (task.skipConflictCheck || !targetPane.connection) return null;
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
@@ -684,30 +880,26 @@ export const useSftpTransfers = ({
: null;
try {
let existingStat: { size: number; mtime: number } | null = null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
}
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
return {
transferId: task.id,
batchId: task.batchId,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -729,6 +921,44 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
return "cancelled";
}
if (defaultAction === "skip") {
cancelledTasksRef.current.add(task.id);
updateTask({ status: "cancelled", endTime: Date.now() });
await completeCancelledTask(task);
return "cancelled";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
const updatedTask: TransferTask = {
...task,
...(duplicateTarget
? {
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
}
: null),
skipConflictCheck: true,
replaceExistingTarget: defaultAction === "replace",
};
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}
setConflicts((prev) => [...prev, conflict]);
updateTask({
status: "pending",
@@ -741,6 +971,10 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.replaceExistingTarget) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding);
}
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
@@ -816,6 +1050,10 @@ export const useSftpTransfers = ({
);
}
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const finalStatus: TransferStatus = dirPartialFailure ? "failed" : "completed";
setTransfers((prev) => {
return prev.map((t) => {
@@ -940,6 +1178,7 @@ export const useSftpTransfers = ({
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const newTasks: TransferTask[] = [];
@@ -965,6 +1204,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: crypto.randomUUID(),
batchId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
@@ -1032,37 +1272,10 @@ export const useSftpTransfers = ({
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
// Cancel parent and all active child streams at the backend.
// Use activeChildIdsRef for immediate visibility (not subject to
// React state batching delays like transfersRef).
const idsToCancel = [transferId];
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.push(childId);
cancelledTasksRef.current.add(childId);
}
}
// Also check rendered state as fallback for transfers started
// via other paths (e.g. startTransfer/processTransfer)
const currentTransfers = transfersRef.current;
for (const t of currentTransfers) {
if (t.parentTaskId === transferId && (t.status === "transferring" || t.status === "pending") && !idsToCancel.includes(t.id)) {
idsToCancel.push(t.id);
}
}
await Promise.all(
idsToCancel.map((id) =>
netcattyBridge.get()!.cancelTransfer!(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}
await cancelBackendTransfers([transferId]);
},
[],
[cancelBackendTransfers],
);
const retryTransfer = useCallback(
@@ -1155,79 +1368,123 @@ export const useSftpTransfers = ({
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) return;
if (!task) {
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
}
setConflicts((prev) => prev.filter((c) => !affectedConflictIds.has(c.transferId)));
if (affectedTasks.length === 0) {
return;
}
if (action === "stop") {
await markBatchStopped(task);
return;
}
if (action === "skip") {
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
for (const affectedTask of affectedTasks) {
await completeCancelledTask(affectedTask);
}
return;
}
let updatedTask = { ...task };
const updatedTasks: TransferTask[] = [];
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = joinPath(getParentPath(task.targetPath), newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints) continue;
const targetSftpId = endpoints.targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
const targetEncoding = endpoints.targetPane.connection?.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
updatedTask = {
...affectedTask,
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: true,
};
} else if (action === "merge") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: false,
};
}
updatedTasks.push(updatedTask);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
prev.map((t) => {
const updatedTask = updatedTaskMap.get(t.id);
return updatedTask
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
: t;
}),
);
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
for (const updatedTask of updatedTasks) {
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
[resolveTaskEndpoints],
[
completeCancelledTask,
conflictDefaultKey,
getDuplicateTarget,
markBatchStopped,
resolveTaskEndpoints,
sftpSessionsRef,
],
);
const activeTransfersCount = useMemo(() => transfers.filter(

View File

@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorSaveCoordinator } from "./textEditorSaveCoordinator.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
test("text editor save coordinator joins duplicate saves already in flight", async () => {
const pending = deferred();
const saved: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await pending.promise;
},
onSavingChange: (saving) => savingStates.push(saving),
});
const first = coordinator.save("remote text");
const second = coordinator.save("remote text");
assert.deepEqual(saved, ["remote text"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(saved, ["remote text"]);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator saves newer content after an in-flight save finishes", async () => {
const firstSave = deferred();
const secondSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = coordinator.save("v1");
const second = coordinator.save("v2");
assert.deepEqual(saved, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(saved, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("text editor save coordinator returns false to duplicate callers when the in-flight save fails", async () => {
const pending = deferred();
const errors: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
throw new Error("denied");
},
onSaveError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
const first = coordinator.save("content");
const second = coordinator.save("content");
pending.resolve();
assert.equal(await first, false);
assert.equal(await second, false);
assert.deepEqual(errors, ["denied"]);
});
test("text editor save coordinator reset prevents an old in-flight save from updating the next file", async () => {
const pending = deferred();
const successes: string[] = [];
const errors: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
},
onSaveSuccess: (content) => successes.push(content),
onSaveError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
onSavingChange: (saving) => savingStates.push(saving),
});
const save = coordinator.save("old file");
coordinator.reset();
pending.resolve();
assert.equal(await save, false);
assert.deepEqual(successes, []);
assert.deepEqual(errors, []);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator reset cancels queued stale saves", async () => {
const firstSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await firstSave.promise;
},
});
const first = coordinator.save("old v1");
const queued = coordinator.save("old v2");
coordinator.reset();
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(await first, false);
assert.equal(await queued, false);
assert.deepEqual(saved, ["old v1"]);
});

View File

@@ -0,0 +1,90 @@
export interface TextEditorSaveCoordinator {
save(content: string): Promise<boolean>;
isSaving(): boolean;
reset(): void;
}
export interface TextEditorSaveCoordinatorOptions {
onSave: (content: string) => Promise<void>;
onSaveStart?: (content: string) => void;
onSaveSuccess?: (content: string) => void;
onSaveError?: (error: unknown) => void;
onSavingChange?: (saving: boolean) => void;
}
interface InFlightSave {
content: string;
promise: Promise<boolean>;
}
export const createTextEditorSaveCoordinator = (
options: TextEditorSaveCoordinatorOptions,
): TextEditorSaveCoordinator => {
let inFlight: InFlightSave | null = null;
let generation = 0;
const notifySavingChange = () => {
options.onSavingChange?.(inFlight !== null);
};
const startSave = (content: string): Promise<boolean> => {
const saveGeneration = generation;
options.onSaveStart?.(content);
const promise = (async () => {
try {
await options.onSave(content);
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveSuccess?.(content);
}
return true;
} catch (error) {
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveError?.(error);
}
return false;
}
})();
const entry = { content, promise };
inFlight = entry;
notifySavingChange();
void promise.finally(() => {
if (inFlight === entry) {
inFlight = null;
notifySavingChange();
}
});
return promise;
};
const save = async (content: string): Promise<boolean> => {
const current = inFlight;
if (current) {
const waitGeneration = generation;
const ok = await current.promise;
if (waitGeneration !== generation) return false;
if (!ok || current.content === content) return ok;
return save(content);
}
return startSave(content);
};
return {
save,
isSaving: () => inFlight !== null,
reset: () => {
generation += 1;
if (inFlight) {
inFlight = null;
notifySavingChange();
}
},
};
};

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
items: { length: 0 },
files,
} as unknown as DataTransfer;
}
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
const events: string[] = [];
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
const results = await uploadFromDataTransfer(
createDataTransfer([file]),
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
writeLocalFile: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onScanningStart: () => events.push("scan:start"),
onScanningEnd: () => events.push("scan:end"),
onTaskCreated: () => events.push("task:create"),
},
resolveConflict: async () => "skip",
},
);
assert.deepEqual(results, [
{ fileName: "conflict.txt", success: false, cancelled: true },
]);
assert.deepEqual(events, ["scan:start", "scan:end"]);
});

View File

@@ -16,14 +16,13 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
interface AutoSyncConfig {
@@ -35,7 +34,6 @@ interface AutoSyncConfig {
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -140,8 +138,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
@@ -150,7 +146,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
@@ -161,7 +156,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
@@ -283,7 +277,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
@@ -437,8 +431,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -49,7 +49,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -1265,6 +1265,7 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
@@ -1272,13 +1273,17 @@ export const useSettingsState = () => {
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,

View File

@@ -271,7 +271,7 @@ export const useSftpState = (
const {
transfers,
conflicts,
conflicts: transferConflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
@@ -282,7 +282,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveTransferConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
@@ -308,6 +308,8 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
@@ -322,6 +324,21 @@ export const useSftpState = (
dismissExternalUpload: dismissTransfer,
});
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
[transferConflicts, uploadConflicts],
);
const resolveAnyConflict = useCallback(
(...args: Parameters<typeof resolveTransferConflict>) => {
const [conflictId] = args;
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
return resolveUploadConflict(...args);
}
return resolveTransferConflict(...args);
},
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// Store methods in a ref to create stable wrapper functions
// This prevents callback reference changes from causing re-renders in consumers
const methodsRef = useRef({
@@ -375,7 +392,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
@@ -430,7 +447,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
@@ -496,7 +513,7 @@ export const useSftpState = (
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,

View File

@@ -0,0 +1,25 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const requestWindowInputFocus = (): void => {
try {
const result = netcattyBridge.get()?.windowFocus?.();
void result?.catch?.(() => undefined);
} catch {
// Browser preview or a disposed Electron bridge.
}
};
export const scheduleWindowInputFocus = (): void => {
const scheduleFrame: (callback: () => void) => unknown =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => {
callback();
return undefined;
};
scheduleFrame(() => {
requestWindowInputFocus();
setTimeout(requestWindowInputFocus, 50);
});
};

View File

@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const {
applyLocalVaultPayload,
applySyncPayload,
buildLocalVaultPayload,
buildSyncPayload,
hasMeaningfulCloudSyncData,
} = await import("./syncPayload.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
hostname: `${id}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${id}`,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts,
groupConfigs: [],
});
test.beforeEach(() => {
localStorage.clear();
});
test("buildSyncPayload treats known hosts as local-only data", () => {
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
assert.equal("knownHosts" in payload, false);
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-only")],
syncedAt: 1,
}),
false,
);
});
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("applySyncPayload ignores legacy cloud known hosts", () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
syncedAt: 1,
};
applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
});
test("applyLocalVaultPayload restores known hosts from local backups", () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-backup")],
syncedAt: 1,
};
applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
});

View File

@@ -58,7 +58,7 @@ import {
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
/** All vault-owned data that participates in cloud sync. */
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
@@ -66,6 +66,7 @@ export interface SyncableVaultData {
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
/** Local trust records. Kept in local backups, excluded from cloud sync. */
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
@@ -93,9 +94,31 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
);
}
/**
* Returns true when a payload contains cloud-sync data.
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
@@ -317,7 +340,6 @@ export function buildSyncPayload(
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
@@ -325,20 +347,30 @@ export function buildSyncPayload(
};
}
/** Build a local backup/restore payload, including local-only trust records. */
export function buildLocalVaultPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
...buildSyncPayload(vault, portForwardingRules),
knownHosts: vault.knownHosts,
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
@@ -349,7 +381,7 @@ export function applySyncPayload(
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
@@ -374,3 +406,17 @@ export function applySyncPayload(
importers.onSettingsApplied?.();
}
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: true });
}

View File

@@ -24,6 +24,7 @@ import type {
AIPanelView,
AIPermissionMode,
AIToolIntegrationMode,
AgentModelPreset,
AISession,
AISessionScope,
ChatMessage,
@@ -66,10 +67,22 @@ import {
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
}
return preset.id === modelId;
}
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
@@ -231,7 +244,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
@@ -608,12 +621,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
// ACP agents can expose their runtime model catalog during session setup.
// Codex also exposes model/reasoning selectors through ACP config options,
// which keeps the picker aligned with the user's installed CLI version.
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
@@ -640,13 +651,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
const runtimePresets = result.models ?? [];
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
[currentAgentId]: runtimePresets,
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
@@ -658,7 +669,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
@@ -668,7 +679,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
// Config.toml with a pinned model → show just that model.
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
@@ -678,13 +693,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// wouldn't work. Empty list disables the picker.
return [];
}
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
return stored;
}
// Default to first preset; for models with thinking levels, use the default level
@@ -698,6 +713,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
@@ -800,6 +821,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// immediately after the first send path starts; `isStreaming` alone does
// not protect the initial draft->session transition.
if (!trimmed || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
@@ -816,8 +841,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
try {
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
if (isDraftMode) {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
@@ -857,7 +880,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
@@ -1088,6 +1110,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}

View File

@@ -638,6 +638,7 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
@@ -1055,6 +1056,7 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onApplyLocalPayload,
onClearLocalData,
}) => {
const { t, resolvedLocale } = useI18n();
@@ -1916,7 +1918,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
/>
</div>
@@ -2612,6 +2614,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}

View File

@@ -16,6 +16,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
@@ -167,7 +168,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (id) owned.add(id);
}
if (owned.size === 0) return;
editorTabStore.forceCloseBySessions([...owned]);
const closed = editorTabStore.forceCloseBySessions([...owned]);
closed.forEach(releaseEditorTabSaveCoordinator);
};
}, []);

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { TransferTask } from "../types.ts";
import { SftpTransferItem } from "./sftp/SftpTransferItem.tsx";
const baseTask: TransferTask = {
id: "transfer-1",
fileName: "archive.tar.gz",
sourcePath: "/local/archive.tar.gz",
targetPath: "/remote/archive.tar.gz",
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status: "failed",
totalBytes: 1024,
transferredBytes: 512,
speed: 0,
error: "Network error",
startTime: 1,
isDirectory: false,
};
const renderTransferItem = (
task: TransferTask,
props: Partial<React.ComponentProps<typeof SftpTransferItem>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(SftpTransferItem, {
task,
onCancel: () => {},
onRetry: () => {},
onDismiss: () => {},
...props,
}),
),
);
test("renders failed transfer actions with custom tooltips and readable labels", () => {
const markup = renderTransferItem(baseTask);
assert.match(markup, /aria-label="Retry: archive\.tar\.gz"/);
assert.match(markup, /aria-label="Dismiss: archive\.tar\.gz"/);
assert.match(markup, /focus-visible:ring-1/);
});
test("renders active transfer cancel action with an item-specific label", () => {
const markup = renderTransferItem({
...baseTask,
status: "transferring",
error: undefined,
speed: 128,
});
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
});
test("renders child resize handle as a keyboard-reachable separator", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-1",
parentTaskId: "transfer-1",
status: "transferring",
error: undefined,
transferredBytes: 256,
speed: 128,
},
{
isChild: true,
childNameColumnWidth: 260,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-label="Resize file name column"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /tabindex="0"/);
});
test("can remove duplicate child resize handles from the tab order", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-2",
parentTaskId: "transfer-1",
status: "pending",
error: undefined,
},
{
isChild: true,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
resizeHandleTabIndex: -1,
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /tabindex="-1"/);
});
test("keeps reveal target and child toggle as separate buttons", () => {
const markup = renderTransferItem(
{
...baseTask,
status: "completed",
error: undefined,
isDirectory: true,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canToggleChildren: true,
isExpanded: false,
childListId: "children-transfer-1",
onToggleChildren: () => {},
},
);
const revealStart = markup.indexOf('<button type="button" class="flex min-w-0 flex-1');
assert.notEqual(revealStart, -1);
const revealEnd = markup.indexOf("</button>", revealStart);
const toggleStart = markup.indexOf('aria-label="Show detail"');
assert.notEqual(toggleStart, -1);
assert.ok(toggleStart > revealEnd);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});

View File

@@ -26,6 +26,7 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
@@ -127,6 +128,8 @@ interface TerminalProps {
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: "theme" | "custom";
customAccent?: string;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -225,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
startupCommand,
@@ -682,18 +687,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// When "Follow Application Theme" is on and there's no active
// preview, skip per-host overrides — all terminals should use the
// UI-matched theme passed via terminalTheme prop.
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
if (followAppTerminalTheme && !themePreviewId) {
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
}
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
let baseTheme = terminalTheme;
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
if (hostTheme) baseTheme = hostTheme;
}
return terminalTheme;
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
chainHosts;
@@ -1725,8 +1733,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu

View File

@@ -24,6 +24,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
applyCustomAccentToTerminalTheme,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
@@ -395,6 +396,8 @@ interface TerminalLayerProps {
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
@@ -455,6 +458,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
accentMode = 'theme',
customAccent = '',
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
@@ -1580,35 +1585,37 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!pane || !theme) {
if (!pane || !baseTheme) {
clearTerminalPreviewVars(sessionId);
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
}, [customThemes]);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.cursor} 78%, ${theme.colors.background} 22%)`);
}, [accentMode, customAccent, customThemes]);
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
if (!themeId || typeof document === 'undefined') {
clearTopTabsPreviewVars();
return;
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!tabsRoot || !theme) {
if (!tabsRoot || !baseTheme) {
clearTopTabsPreviewVars();
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = fg;
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
@@ -1625,8 +1632,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
}, [customThemes]);
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--accent))');
}, [accentMode, customAccent, customThemes]);
useEffect(() => {
return () => {
@@ -1889,10 +1896,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const resolvedPreviewTheme = useMemo(() => {
const themeId = previewedOrVisibleThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
const baseTheme = TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, previewedOrVisibleThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
@@ -2201,6 +2209,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
style={{
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
['--terminal-sidepanel-accent' as never]: resolvedPreviewTheme.colors.cursor,
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
backgroundColor: 'var(--terminal-sidepanel-bg)',
@@ -2223,6 +2232,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'sftp'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2240,6 +2252,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'scripts'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2257,6 +2272,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'theme'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2274,6 +2292,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2525,6 +2546,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
@@ -2641,6 +2664,8 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorModalSnapshot } from "./TextEditorModal.tsx";
import { createTextEditorSaveCoordinator } from "../application/state/textEditorSaveCoordinator.ts";
test("promotion snapshot uses the latest saved baseline after a save", async () => {
let baselineContent = "old";
let content = "saved";
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {},
onSaveSuccess: (savedContent) => {
baselineContent = savedContent;
},
});
await coordinator.save(content);
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => baselineContent,
getContent: () => content,
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => false,
});
assert.equal(snapshot?.baselineContent, "saved");
assert.equal(snapshot?.content, "saved");
});
test("promotion snapshot is blocked while saving", () => {
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => "old",
getContent: () => "new",
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => true,
});
assert.equal(snapshot, null);
});

View File

@@ -9,14 +9,20 @@ import { getLanguageId } from '../lib/sftpFileUtils';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { toast } from './ui/toast';
import { TextEditorPane } from './editor/TextEditorPane';
import { promptUnsavedChanges } from './editor/UnsavedChangesDialog';
import { useI18n } from '../application/i18n/I18nProvider';
import { scheduleWindowInputFocus } from '../application/state/windowInputFocus';
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from '../application/state/textEditorSaveCoordinator';
import type { HotkeyScheme, KeyBinding } from '../domain/models';
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
export interface TextEditorModalSnapshot {
/** The file name at the time of promotion (modal's fileName prop). */
fileName: string;
/** The clean baseline content at the time the modal was opened. */
/** The clean baseline content at the time of promotion. */
baselineContent: string;
/** The current (possibly-dirty) editor content. */
content: string;
@@ -28,6 +34,31 @@ export interface TextEditorModalSnapshot {
viewState: Monaco.editor.ICodeEditorViewState | null;
}
export interface TextEditorModalSnapshotSource {
fileName: string;
getBaselineContent: () => string;
getContent: () => string;
languageId: string;
wordWrap: boolean;
getViewState: () => Monaco.editor.ICodeEditorViewState | null;
isSaving: () => boolean;
}
export const createTextEditorModalSnapshot = (
source: TextEditorModalSnapshotSource,
): TextEditorModalSnapshot | null => {
if (source.isSaving()) return null;
return {
fileName: source.fileName,
baselineContent: source.getBaselineContent(),
content: source.getContent(),
languageId: source.languageId,
wordWrap: source.wordWrap,
viewState: source.getViewState(),
};
};
interface TextEditorModalProps {
open: boolean;
onClose: () => void;
@@ -57,51 +88,128 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const { t } = useI18n();
const [content, setContent] = useState(initialContent);
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
const closePromptRef = useRef<Promise<void> | null>(null);
const onSaveRef = useRef(onSave);
const tRef = useRef(t);
const saveCoordinatorRef = useRef<TextEditorSaveCoordinator | null>(null);
// Latest view state captured from Pane's onContentChange — used by handlePromote
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
// Derived: whether the current content differs from the clean baseline
const hasChanges = content !== initialContent;
const hasChanges = content !== baselineContent;
if (!saveCoordinatorRef.current) {
saveCoordinatorRef.current = createTextEditorSaveCoordinator({
onSave: (contentToSave) => onSaveRef.current(contentToSave),
onSaveStart: () => {
setSaveError(null);
},
onSaveSuccess: (savedContent) => {
setBaselineContent(savedContent);
baselineContentRef.current = savedContent;
toast.success(tRef.current('sftp.editor.saved'), 'SFTP');
},
onSaveError: (error) => {
const msg = error instanceof Error
? error.message
: tRef.current('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
},
onSavingChange: (nextSaving) => {
savingRef.current = nextSaving;
setSaving(nextSaving);
},
});
}
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
tRef.current = t;
}, [t]);
// Reset all state when a new file is opened
useEffect(() => {
saveCoordinatorRef.current?.reset();
setContent(initialContent);
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
}, []);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(content);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
await saveContent();
}, [saveContent]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
if (!confirmed) return;
if (closePromptRef.current) return;
const closeTask = (async () => {
if (contentRef.current !== baselineContentRef.current) {
const choice = await promptUnsavedChanges(fileName);
if (choice === 'cancel') return;
if (choice === 'save') {
const saved = await saveContent();
if (!saved) return;
if (contentRef.current !== baselineContentRef.current) return;
}
}
onClose();
scheduleWindowInputFocus();
})().finally(() => {
closePromptRef.current = null;
});
closePromptRef.current = closeTask;
}, [fileName, onClose, saveContent]);
useEffect(() => {
contentRef.current = content;
}, [content]);
useEffect(() => {
baselineContentRef.current = baselineContent;
}, [baselineContent]);
useEffect(() => {
savingRef.current = saving;
}, [saving]);
useEffect(() => {
if (!open) {
closePromptRef.current = null;
}
onClose();
}, [hasChanges, onClose, t]);
}, [open]);
useEffect(() => {
if (open) scheduleWindowInputFocus();
}, [open]);
const handleContentChange = useCallback(
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
setContent(nextContent);
contentRef.current = nextContent;
viewStateRef.current = viewState;
},
[],
@@ -109,15 +217,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const handlePromote = useCallback(() => {
if (!onPromoteToTab) return;
onPromoteToTab({
const snapshot = createTextEditorModalSnapshot({
fileName,
baselineContent: initialContent,
content,
getBaselineContent: () => baselineContentRef.current,
getContent: () => contentRef.current,
languageId,
wordWrap: editorWordWrap,
viewState: viewStateRef.current,
getViewState: () => viewStateRef.current,
isSaving: () => savingRef.current,
});
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
if (snapshot) onPromoteToTab(snapshot);
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>

View File

@@ -35,7 +35,7 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
<div
className={cn(
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden 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-user]:ml-auto group-[.is-user]:overflow-hidden 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-[7px]',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}

View File

@@ -233,7 +233,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>

View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { canSendWithAgent, findEnabledExternalAgent } from './agentSendEligibility';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
const agents: ExternalAgentConfig[] = [
{
id: 'enabled-agent',
name: 'Enabled Agent',
command: '/usr/local/bin/enabled-agent',
enabled: true,
},
{
id: 'disabled-agent',
name: 'Disabled Agent',
command: '/usr/local/bin/disabled-agent',
enabled: false,
},
];
test('canSendWithAgent allows Catty and enabled external agents', () => {
assert.equal(canSendWithAgent('catty', agents), true);
assert.equal(canSendWithAgent('enabled-agent', agents), true);
});
test('canSendWithAgent blocks missing or disabled external agents', () => {
assert.equal(canSendWithAgent('disabled-agent', agents), false);
assert.equal(canSendWithAgent('missing-agent', agents), false);
});
test('findEnabledExternalAgent ignores disabled external agents', () => {
assert.equal(findEnabledExternalAgent(agents, 'enabled-agent')?.name, 'Enabled Agent');
assert.equal(findEnabledExternalAgent(agents, 'disabled-agent'), undefined);
});

View File

@@ -0,0 +1,15 @@
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
export function findEnabledExternalAgent(
agents: ExternalAgentConfig[],
agentId: string,
): ExternalAgentConfig | undefined {
return agents.find((agent) => agent.id === agentId && agent.enabled);
}
export function canSendWithAgent(
agentId: string,
agents: ExternalAgentConfig[],
): boolean {
return agentId === "catty" || Boolean(findEnabledExternalAgent(agents, agentId));
}

View File

@@ -31,6 +31,18 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
isProviderContinuationForSource,
mergeProviderContinuation,
normalizeProviderContinuationOptions,
withProviderContinuationSource,
type OpenAIChatAssistantFields,
type ProviderContinuation,
type ProviderContinuationOptions,
type ProviderContinuationSource,
} from '../../../infrastructure/ai/providerContinuation';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -41,12 +53,22 @@ interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
providerMetadata?: unknown;
}
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
textDelta?: string;
delta?: string;
providerMetadata?: unknown;
}
/** Shape of a raw provider chunk from the Vercel AI SDK fullStream. */
interface RawChunk {
type: 'raw';
rawValue: unknown;
}
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
@@ -56,6 +78,7 @@ interface ToolCallChunk {
toolName: string;
input?: unknown;
args?: unknown;
providerMetadata?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
@@ -105,6 +128,7 @@ type StreamChunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
| RawChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
@@ -119,7 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
@@ -153,6 +177,23 @@ export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
interface CattyProviderContinuationContext {
source: ProviderContinuationSource;
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
}
type AssistantContentPart =
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
return parts[0].text;
}
return parts;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -251,6 +292,7 @@ export interface UseAIChatStreamingReturn {
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
@@ -389,6 +431,7 @@ export function useAIChatStreaming({
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
): Promise<void> => {
const result = streamText({
model,
@@ -397,6 +440,7 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
includeRawChunks: true,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
@@ -412,6 +456,42 @@ export function useAIChatStreaming({
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
let rafId: number | null = null;
const ensureAssistantMessage = (): string => {
if (lastAddedRole !== 'tool') return activeMsgId;
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
return activeMsgId;
};
const updateAssistantContinuation = (
messageId: string,
continuation: ProviderContinuation | undefined,
thinkingText = '',
) => {
if (!continuation && !thinkingText) return;
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
updateMessageById(streamSessionId, messageId, msg => {
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
return {
...msg,
...(providerContinuation ? { providerContinuation } : {}),
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
};
});
};
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
return typeof reasoningContent === 'string' ? reasoningContent : '';
};
const flushText = () => {
if (pendingText) {
@@ -455,6 +535,11 @@ export function useAIChatStreaming({
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
if (providerOptions) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
}
if (text) {
pendingText += text;
if (rafId === null) {
@@ -469,25 +554,30 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text;
if (rText) {
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
thinking: rText,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
thinking: (msg.thinking || '') + rText,
}));
}
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
const continuation = rText || providerOptions
? {
reasoningParts: [{
text: rText,
...(providerOptions ? { providerOptions } : {}),
}],
} satisfies ProviderContinuation
: undefined;
if (continuation || rText) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, rText);
}
break;
}
case 'raw': {
const typedChunk = chunk as RawChunk;
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
if (continuation) {
cancelPendingFlush();
flushText();
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
}
break;
}
@@ -503,7 +593,9 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
updateMessageById(streamSessionId, messageId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
@@ -513,6 +605,13 @@ export function useAIChatStreaming({
executionStatus: 'running',
statusText: undefined,
}));
if (providerOptions) {
updateAssistantContinuation(messageId, {
toolCallProviderOptionsById: {
[typedChunk.toolCallId]: providerOptions,
},
});
}
break;
}
case 'tool-result': {
@@ -778,20 +877,15 @@ export function useAIChatStreaming({
return;
}
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig({
...context.activeProvider,
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
const continuationContext: CattyProviderContinuationContext = {
source: {
providerConfigId: context.activeProvider.id,
providerType: context.activeProvider.providerId,
modelId: activeModelId,
},
openAIChatAssistantFields: [],
};
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
@@ -818,7 +912,9 @@ export function useAIChatStreaming({
};
const sdkMessages: Array<ModelMessage> = [];
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
@@ -837,30 +933,76 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
if (resolvedCalls.length > 0) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
sdkMessages.push({
role: 'assistant',
content: toAssistantModelContent(contentParts),
});
if (currentMessageFollowsToolResult) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
@@ -873,6 +1015,7 @@ export function useAIChatStreaming({
})),
});
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
@@ -890,7 +1033,37 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig(
{
...context.activeProvider,
defaultModel: activeModelId,
},
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
},
);
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
sdkMessages,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_codex',
name: 'Codex CLI',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'discovered_codex',
'codex',
{ path: '/usr/local/bin/codex', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'catty');
});
test('buildManagedAgentState keeps unrelated defaults when removing stale managed agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_claude',
name: 'Claude Code',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'custom-agent',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-claude-wrapper',
name: 'My Claude Wrapper',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-claude-wrapper',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(state.agents, agents);
assert.equal(state.defaultAgentId, 'my-claude-wrapper');
});
test('buildManagedAgentState only rewrites settings-managed discovered agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-codex-wrapper',
name: 'My Codex Wrapper',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-codex-wrapper',
'codex',
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['my-codex-wrapper', 'discovered_codex'],
);
assert.equal(state.agents[0], agents[0]);
assert.equal(state.defaultAgentId, 'my-codex-wrapper');
});

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
canPromoteTextEditor,
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
assert.equal(isTextEditorReadOnly({ saving: true }), true);
assert.equal(isTextEditorReadOnly({ saving: false }), false);
});
test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
assert.match(savingMarkup, /disabled=""/);
assert.doesNotMatch(idleMarkup, /disabled=""/);
});

View File

@@ -16,9 +16,10 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
const viteEnv = import.meta.env ?? { BASE_URL: "/" };
const monacoBasePath = viteEnv.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
: `${viteEnv.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -116,6 +117,9 @@ const hslToHex = (hslString: string): string => {
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
@@ -143,6 +147,9 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return '';
}
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
@@ -170,6 +177,27 @@ export interface TextEditorPaneProps {
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
}
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
title={title}
>
<Maximize2 size={14} />
</Button>
);
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
fileName,
content,
@@ -202,7 +230,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever immersive-mode or base theme colors change
@@ -253,6 +281,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
@@ -309,6 +338,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
if (saving) return;
const editor = editorRef.current;
if (!editor) return;
@@ -337,16 +367,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
})),
);
editor.focus();
}, [readClipboardText]);
}, [readClipboardText, saving]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleEditorChange = useCallback((value: string | undefined) => {
if (saving) return;
const editor = editorRef.current;
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
}, [onContentChange]);
}, [onContentChange, saving]);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
@@ -504,15 +535,11 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
{chrome === 'modal' && onPromoteToTab && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
<TextEditorPromoteButton
saving={saving}
onPromoteToTab={onPromoteToTab}
title={t('sftp.editor.maximize')}
>
<Maximize2 size={14} />
</Button>
/>
)}
{/* Close button — modal chrome only */}
@@ -556,6 +583,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
tabSize: 2,
insertSpaces: true,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: isTextEditorReadOnly({ saving }),
domReadOnly: isTextEditorReadOnly({ saving }),
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },

View File

@@ -8,7 +8,7 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
import { saveEditorTab } from '../../application/state/editorTabSave';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
@@ -60,21 +60,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
}, [tabId]);
const handleSave = useCallback(async () => {
// Read live store state at call time — React state snapshot lags the store
// by one microtask, so a keystroke between onChange and this save would
// otherwise leave us writing stale content and marking a stale baseline.
const current = editorTabStore.getTab(tabId);
if (!current) return;
if (current.savingState === 'saving') return;
editorTabStore.setSavingState(tabId, 'saving');
try {
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
editorTabStore.markSaved(tabId, current.content);
const ok = await saveEditorTab(tabId);
if (ok) {
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
editorTabStore.setSavingState(tabId, 'error', msg);
} else {
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
toast.error(msg, 'SFTP');
}
}, [tabId, t]);

View File

@@ -17,11 +17,7 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
@@ -36,7 +32,6 @@ import type {
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
@@ -48,6 +43,11 @@ import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
import {
areExternalAgentListsEqual,
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
// ---------------------------------------------------------------------------
// Props
@@ -80,54 +80,6 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -179,11 +131,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);

View File

@@ -1,7 +1,12 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import {
applyLocalVaultPayload,
buildLocalVaultPayload,
buildSyncPayload,
applySyncPayload,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
@@ -29,7 +34,7 @@ export default function SettingsSyncTab(props: {
} = props;
const { t } = useI18n();
const onBuildPayload = useCallback((): SyncPayload => {
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
@@ -51,15 +56,26 @@ export default function SettingsSyncTab(props: {
}
}
return effectiveRules;
}, [portForwardingRules]);
const onBuildPayload = useCallback((): SyncPayload => {
return buildSyncPayload(vault, getEffectivePortForwardingRules());
}, [vault, getEffectivePortForwardingRules]);
const onBuildLocalPayload = useCallback((): SyncPayload => {
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
return buildLocalVaultPayload(
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
getEffectivePortForwardingRules(),
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
@@ -69,7 +85,23 @@ export default function SettingsSyncTab(props: {
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const onApplyLocalPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applyLocalVaultPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {
@@ -82,6 +114,7 @@ export default function SettingsSyncTab(props: {
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onApplyLocalPayload={onApplyLocalPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>

View File

@@ -300,16 +300,6 @@ export default function SettingsTerminalTab(props: {
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
// Mosh settings state
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [moshDetectStatus, setMoshDetectStatus] = useState<
| { kind: "idle" }
| { kind: "running" }
| { kind: "found"; path: string }
| { kind: "not-found"; searchedPaths: string[] }
>({ kind: "idle" });
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
const discoveredShells = useDiscoveredShells();
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
if (!terminalSettings.localShell) return false;
@@ -465,109 +455,6 @@ export default function SettingsTerminalTab(props: {
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, discoveredShells, t]);
// Validate mosh client path when it changes (debounced)
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const moshPath = terminalSettings.moshClientPath;
if (!moshPath) {
setMoshValidation(null);
return;
}
// The shared validatePath bridge resolves bare names through PATH (good
// for localShell where "powershell.exe" is a valid choice), but
// startMoshSession treats moshClientPath as a literal filesystem path —
// so any non-absolute entry would look valid here yet fail at connect
// time. Gate on absolute paths first; accept ~ since the main process
// will expand it. Tolerant across platforms so e.g. a user pasting a
// Windows-style absolute path on macOS still gets a real error
// downstream rather than a misleading "not absolute".
const looksAbsolute =
moshPath.startsWith("/") ||
moshPath.startsWith("~") ||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
moshPath.startsWith("\\\\");
if (!looksAbsolute) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
return;
}
if (!bridge?.validatePath) {
setMoshValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(moshPath, "file").then((result) => {
if (result.exists && result.isFile && !result.isExecutable) {
// Stays consistent with startMoshSession's isExecutableFile check —
// a regular file without the execute bit can't actually launch.
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
} else if (result.exists && result.isFile) {
setMoshValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
} else {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
}
}).catch(() => {
setMoshValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.moshClientPath, t]);
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
let canceled = false;
bridge.detectMoshClient()
.then((result) => {
if (!canceled) {
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
}
})
.catch(() => {
if (!canceled) setAutoDetectedMoshPath(null);
});
return () => {
canceled = true;
};
}, []);
const handleDetectMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
setMoshDetectStatus({ kind: "running" });
try {
const result = await bridge.detectMoshClient();
if (result.found && result.path) {
setMoshDetectStatus({ kind: "found", path: result.path });
// Auto-fill the input only when it is empty so we don't override
// a value the user is in the middle of editing.
if (!terminalSettings.moshClientPath) {
updateTerminalSetting("moshClientPath", result.path);
}
} else {
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
}
} catch (err) {
console.error("[Settings] detectMoshClient failed:", err);
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
}
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
const handleBrowseMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.pickMoshClient) return;
try {
const result = await bridge.pickMoshClient();
if (!result.canceled && result.filePath) {
updateTerminalSetting("moshClientPath", result.filePath);
setMoshDetectStatus({ kind: "idle" });
}
} catch (err) {
console.error("[Settings] pickMoshClient failed:", err);
}
}, [updateTerminalSetting]);
// Validate directory path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
@@ -1158,74 +1045,6 @@ export default function SettingsTerminalTab(props: {
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.mosh.client")}
description={t("settings.terminal.mosh.client.desc")}
>
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
<Input
value={terminalSettings.moshClientPath}
placeholder={t("settings.terminal.mosh.client.placeholder")}
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
className={cn(
"flex-1",
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDetectMosh}
disabled={moshDetectStatus.kind === "running"}
>
{t("settings.terminal.mosh.detect")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleBrowseMosh}
>
{t("settings.terminal.mosh.browse")}
</Button>
</div>
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
</span>
)}
{moshValidation && !moshValidation.valid && moshValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{moshValidation.message}
</span>
)}
{moshDetectStatus.kind === "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
</span>
)}
{moshDetectStatus.kind === "not-found" && (
<span className="text-xs text-destructive flex items-start gap-1">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>
{t("settings.terminal.mosh.notDetected")}
{moshDetectStatus.searchedPaths.length > 0 && (
<>
{" "}
<span className="text-muted-foreground">
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
</span>
</>
)}
</span>
</span>
)}
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />

View File

@@ -0,0 +1,72 @@
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
import {
type ManagedAgentKey,
} from "../../../../infrastructure/ai/managedAgents";
import type { AgentPathInfo } from "./types";
import { AGENT_DEFAULTS } from "./types";
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || "").trim();
return normalized.includes("/") || normalized.includes("\\");
}
function getAutoManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
}
export function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
export function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: otherAgents,
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
return {
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
};
}

View File

@@ -7,12 +7,16 @@ import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
interface ConflictItem {
transferId: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;
@@ -21,7 +25,7 @@ interface ConflictItem {
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: 'replace' | 'skip' | 'duplicate') => void;
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
@@ -36,13 +40,14 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return new Date(timestamp).toLocaleString();
};
const handleAction = (action: 'replace' | 'skip' | 'duplicate') => {
if (applyToAll) {
// Apply to all conflicts
conflicts.forEach(c => onResolve(c.transferId, action));
} else {
onResolve(conflict.transferId, action);
}
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
setApplyToAll(false);
};
@@ -95,7 +100,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
</div>
</div>
{conflicts.length > 1 && (
{sameTypeConflictCount > 1 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
@@ -103,12 +108,19 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onChange={(e) => setApplyToAll(e.target.checked)}
className="rounded border-border"
/>
{t('sftp.conflict.applyToAll', { count: conflicts.length })}
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
</label>
)}
</div>
<DialogFooter className="flex gap-2">
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
<Button
variant="destructive"
onClick={() => handleAction('stop')}
className="flex-1"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
@@ -121,8 +133,18 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onClick={() => handleAction('duplicate')}
className="flex-1"
>
{t('sftp.conflict.action.keepBoth')}
{t('sftp.conflict.action.duplicate')}
</Button>
{conflict.isDirectory && (
<Button
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="flex-1"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
<Button
variant="default"
onClick={() => handleAction('replace')}

View File

@@ -39,24 +39,39 @@ interface SftpTransferItemProps {
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
onSetNameColumnWidth?: (width: number) => void;
childNameColumnMinWidth?: number;
childNameColumnMaxWidth?: number;
childListId?: string;
resizeHandleTabIndex?: number;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
);
const IconButtonWithTooltip: React.FC<{
label: string;
children: React.ReactElement;
}> = ({ label, children }) => (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
@@ -73,6 +88,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
onSetNameColumnWidth,
childNameColumnMinWidth = 160,
childNameColumnMaxWidth = 480,
childListId,
resizeHandleTabIndex = 0,
}) => {
const { t } = useI18n();
@@ -184,29 +204,65 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
const hasFooterContent = showTransferSizeCalculation || showFailedError;
const retryActionLabel = t('sftp.transfers.retryAction');
const cancelActionLabel = t('common.cancel');
const dismissActionLabel = t('sftp.transfers.dismissAction');
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
const setNameColumnWidth = (width: number) => {
const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width));
onSetNameColumnWidth?.(nextWidth);
};
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onSetNameColumnWidth) return;
const step = event.shiftKey ? 40 : 10;
if (event.key === 'ArrowLeft') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth - step);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth + step);
} else if (event.key === 'Home') {
event.preventDefault();
setNameColumnWidth(childNameColumnMinWidth);
} else if (event.key === 'End') {
event.preventDefault();
setNameColumnWidth(childNameColumnMaxWidth);
}
};
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
<RefreshCw size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
<IconButtonWithTooltip label={cancelActionLabel}>
<Button variant="ghost" size="icon" className={cn(actionButtonClass, "text-destructive hover:text-destructive")} onClick={onCancel} aria-label={actionAriaLabel(cancelActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
<IconButtonWithTooltip label={dismissActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
</div>
);
if (isChild) {
return (
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
style={{
@@ -222,13 +278,25 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
onMouseDown={onResizeNameColumn}
title="Resize file name column"
>
<GripVertical size={10} />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onMouseDown={onResizeNameColumn}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-label={resizeNameColumnLabel}
aria-orientation="vertical"
aria-valuemin={childNameColumnMinWidth}
aria-valuemax={childNameColumnMaxWidth}
aria-valuenow={childNameColumnWidth}
tabIndex={resizeHandleTabIndex}
>
<GripVertical size={10} />
</div>
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0">
{childProgressBar}
</div>
@@ -236,12 +304,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{actionButtons}
</div>
</div>
);
}
) : (() => {
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const titleBlock = (
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
@@ -255,21 +321,29 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
{canToggleChildren && (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggleChildren}
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
>
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
)}
</div>
);
);
return (
const toggleChildrenButton = canToggleChildren ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onToggleChildren}
aria-label={toggleChildrenLabel}
aria-expanded={isExpanded}
aria-controls={childListId}
>
{toggleChildrenLabel}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</TooltipTrigger>
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
</Tooltip>
) : null;
return (
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
@@ -290,6 +364,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
@@ -341,6 +417,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
</div>
);
})();
return (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
{content}
</TooltipProvider>
);
};
@@ -362,6 +445,10 @@ const arePropsEqual = (
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;

View File

@@ -29,9 +29,11 @@ const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
const childListIdForTask = (taskId: string) => `sftp-transfer-children-${taskId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
interface TransferChildListProps {
childTasks: TransferTask[];
childListId: string;
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
@@ -40,10 +42,12 @@ interface TransferChildListProps {
onCancel: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
onSetNameColumnWidth: (width: number) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childListId,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
@@ -52,6 +56,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
onCancel,
onRetry,
onDismiss,
onSetNameColumnWidth,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
@@ -102,6 +107,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
return (
<div
id={childListId}
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
@@ -121,7 +127,11 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
task={child}
isChild
childNameColumnWidth={childNameWidth}
childNameColumnMinWidth={MIN_CHILD_NAME_WIDTH}
childNameColumnMaxWidth={MAX_CHILD_NAME_WIDTH}
onResizeNameColumn={onResizeNameColumn}
onSetNameColumnWidth={onSetNameColumnWidth}
resizeHandleTabIndex={visibleIndex === 0 ? 0 : -1}
onCancel={() => onCancel(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
@@ -303,6 +313,12 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
document.body.style.userSelect = "none";
}, [childNameWidth]);
const handleChildColumnWidthSet = useCallback((width: number) => {
const nextWidth = Math.max(MIN_CHILD_NAME_WIDTH, Math.min(MAX_CHILD_NAME_WIDTH, width));
setChildNameWidth(nextWidth);
persistChildNameWidth(nextWidth);
}, [persistChildNameWidth, setChildNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
@@ -369,6 +385,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
const childListId = childListIdForTask(task.id);
return (
<React.Fragment key={task.id}>
@@ -377,6 +394,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
childListId={childListId}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
if (task.sourceConnectionId === "external") {
@@ -399,8 +417,10 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childListId={childListId}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
onSetNameColumnWidth={handleChildColumnWidthSet}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}

View File

@@ -5,6 +5,7 @@ import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewPaneActionsParams {
@@ -139,12 +140,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("left");
@@ -155,12 +162,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("right");

View File

@@ -2,6 +2,10 @@ import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
@@ -23,8 +27,8 @@ interface UseSftpViewTabsResult {
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleCloseTabLeft: (tabId: string) => Promise<void>;
handleCloseTabRight: (tabId: string) => Promise<void>;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
@@ -53,13 +57,41 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {
sftpRef.current.closeTab("left", tabId);
}, [sftpRef]);
const confirmCloseEditorTabsByConnection = useCallback(async (connectionId: string): Promise<boolean> => {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, []);
const handleCloseTabRight = useCallback((tabId: string) => {
sftpRef.current.closeTab("right", tabId);
}, [sftpRef]);
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const connectionId = pane?.connection?.id;
if (connectionId) {
const ok = await confirmCloseEditorTabsByConnection(connectionId);
if (!ok) return;
}
sftpRef.current.closeTab(side, tabId);
}, [confirmCloseEditorTabsByConnection, sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => (
handleCloseSftpTab("left", tabId)
), [handleCloseSftpTab]);
const handleCloseTabRight = useCallback((tabId: string) => (
handleCloseSftpTab("right", tabId)
), [handleCloseSftpTab]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);

View File

@@ -47,3 +47,72 @@ test("still trims prompt decorations out of the detected input", () => {
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, "do");
});
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
// Real-world PS1 captured from oh-my-posh themed bash on a server:
// "<U+F31B> root@oracle ~ <U+F105> " then user input
const term = createFakeTerm(" root@oracle ~  ls", 21);
const result = getAlignedPrompt(term as never, "ls", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root@oracle ~  ");
assert.equal(result.prompt.userInput, "ls");
});
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
const term = createFakeTerm(" root  ~  git", 16);
const result = getAlignedPrompt(term as never, "git", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "git");
assert.ok(result.prompt.promptText.endsWith(" "));
});
test("PUA char without trailing space is not a prompt boundary", () => {
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
const term = createFakeTerm("echo foo", 13);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, false);
});
test("keeps typed command intact when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = `$ ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("prefers standard prompt terminator over later Powerline glyphs", () => {
const lineText = "$ echo  foo";
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo  foo");
});
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = ` root  ~  ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root  ~  ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});

View File

@@ -17,7 +17,11 @@ const sshHost: Host = {
protocol: "ssh",
};
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
const renderToolbar = (
host: Host,
status: "connecting" | "connected" | "disconnected" = "connected",
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
@@ -28,6 +32,7 @@ const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconn
onOpenSFTP: () => {},
onOpenScripts: () => {},
onOpenTheme: () => {},
...props,
}),
),
);
@@ -52,3 +57,15 @@ test("hides SFTP for local terminal sessions", () => {
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
});
test("uses the terminal active button color for pressed toolbar actions", () => {
const markup = renderToolbar(sshHost, "connected", {
isSearchOpen: true,
onToggleSearch: () => {},
});
assert.match(
markup,
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});

View File

@@ -71,6 +71,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const hidesSftp = isLocalTerminal || isSerialTerminal;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
const activeButtonStyle: React.CSSProperties = {
backgroundColor: 'var(--terminal-toolbar-btn-active)',
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
@@ -111,6 +114,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
style={isComposeBarOpen ? activeButtonStyle : undefined}
>
<TextCursorInput size={12} />
</Button>
@@ -127,6 +131,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
style={isSearchOpen ? activeButtonStyle : undefined}
>
<Search size={12} />
</Button>

View File

@@ -1,8 +1,8 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Command history (highest priority)
* 2. @withfig/autocomplete specs (subcommands, options, args)
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
@@ -66,6 +66,11 @@ export interface CompletionContext {
isOptionArg: boolean;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
@@ -170,10 +175,13 @@ export async function getCompletions(
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, undefined)
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
@@ -226,21 +234,16 @@ export async function getCompletions(
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const specPromise = ctx.commandName && ctx.wordIndex >= 0
? getSpecSuggestions(ctx)
: Promise.resolve([]);
const pathPromise = canQueryPaths && pathCheck.shouldComplete
? getPathSuggestions(ctx, {
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})
: Promise.resolve([]);
: [];
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
for (const suggestion of specSugs) {
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
@@ -313,26 +316,26 @@ function normalizeHistoryPathPrefix(token: string): string {
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return await getCommandNameSuggestions(ctx.currentWord);
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return [];
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return [];
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return [];
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
@@ -348,11 +351,11 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
if (suggestions.length >= 10) break;
}
}
return suggestions;
return { suggestions };
}
// Navigate the spec tree based on typed tokens
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
@@ -387,7 +390,7 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return suggestions;
return { suggestions };
}
}
@@ -442,7 +445,10 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
}
}
return suggestions;
return {
suggestions,
pathArgs: resolved.args,
};
}
/**

View File

@@ -3,8 +3,8 @@
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
* etc.) and choose the most reliable split for prompt text vs. user input.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
@@ -62,6 +62,16 @@ function replacePromptUserInput(
};
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return null;
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
@@ -141,9 +151,23 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
/** Characters that commonly end a shell prompt */
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);
/**
* Whether a character lives in the Unicode Private Use Area (U+E000U+F8FF).
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
* fall here. A PUA char followed by a space is common in themed prompt
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
* characters and reconciled with the typed buffer when available.
*/
function isPuaChar(ch: string): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
@@ -154,15 +178,18 @@ function findPromptBoundary(lineText: string): number {
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastBoundary = -1;
let lastStandardBoundary = -1;
let lastPuaBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
const isStandard = PROMPT_CHARS.has(ch);
const isPua = !isStandard && isPuaChar(ch);
if (!PROMPT_CHARS.has(ch)) continue;
if (!isStandard && !isPua) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
@@ -222,11 +249,17 @@ function findPromptBoundary(lineText: string): number {
}
}
// Record this as a candidate boundary
lastBoundary = nextChar === " " ? i + 2 : i + 1;
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
if (isStandard) {
lastStandardBoundary = boundary;
} else {
lastPuaBoundary = boundary;
}
}
return lastBoundary;
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
}
/**
@@ -312,6 +345,21 @@ export function getAlignedPrompt(
alignedTyped: typedBuffer,
};
}
const cursorLinePrefix = getCursorLinePrefix(term);
if (cursorLinePrefix?.endsWith(typedBuffer)) {
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
if (promptText.length > 0) {
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -107,11 +107,6 @@ export function shouldDoPathCompletion(
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
// Generators field often indicates path completion (e.g., cd)
if (arg.generators) {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
}
}

View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
type MockDirEntry = {
name: string;
type: "file" | "directory" | "symlink";
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const storySpec: FigSpec = {
name: "story",
subcommands: [
{
name: "open",
args: { template: "filepaths" },
},
{
name: "pick",
args: { name: "item", generators: {} },
},
],
};
const bridgeState: { localEntries: MockDirEntry[] } = {
localEntries: [],
};
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => ["story"],
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
listAutocompleteLocalDir: async (
_path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
const prefix = (filterPrefix ?? "").toLowerCase();
const entries = bridgeState.localEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? bridgeState.localEntries.length);
return { success: true, entries };
},
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
});
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
recordCommand("story open package-lock.json", "host-1");
const completions = await getCompletions("story open pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "story open package.json");
const historyIndex = completions.findIndex((entry) =>
entry.source === "history" && entry.text === "story open package-lock.json"
);
assert.ok(historyIndex > 0);
});
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
recordCommand("story pick package-choice", "host-1");
const completions = await getCompletions("story pick pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "history");
assert.equal(completions[0]?.text, "story pick package-choice");
assert.equal(completions.some((entry) => entry.source === "path"), false);
});

View File

@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {
terminalEmulationType: "xterm-256color",
moshClientPath: "/usr/local/bin/mosh-client",
},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal("moshClientPath" in capturedOptions, false);
assert.equal(capturedOptions.hostname, "example.test");
assert.equal(capturedOptions.port, 2200);
});
test("startMosh passes the saved password to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "saved-secret",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.username, "alice");
assert.equal(capturedOptions.password, "saved-secret");
});

View File

@@ -754,14 +754,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
keys: ctx.keys,
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: ctx.host.username || "root",
username: resolvedAuth.username || "root",
password: effectivePassword,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,

View File

@@ -494,9 +494,8 @@ export interface TerminalSettings {
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
// Mosh Connection
// Absolute path to the local `mosh` client binary. Empty triggers
// auto-discovery (PATH + Homebrew/MacPorts/nix fallbacks). When set,
// the value is used as-is and a missing file produces a clear error.
// Legacy override retained for old settings payloads and internal callers.
// The normal UI path uses Netcatty's bundled mosh-client.
moshClientPath: string;
// Server Stats Display (Linux only)
@@ -644,7 +643,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Empty = auto-detect mosh on PATH / common install dirs
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
@@ -781,6 +780,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
export interface TransferTask {
id: string;
batchId?: string;
fileName: string;
originalFileName?: string;
sourcePath: string;
@@ -805,14 +805,21 @@ export interface TransferTask {
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
export type FileConflictAction = 'stop' | 'skip' | 'replace' | 'duplicate' | 'merge';
export interface FileConflict {
transferId: string;
batchId?: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;

View File

@@ -156,13 +156,11 @@ test("only non-hosts entity shrinks → reports that entity", () => {
}
});
test("knownHosts shrink triggers (security-sensitive)", () => {
test("knownHosts shrink is ignored because known hosts are local-only", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });
const out = payload({ knownHosts: kh(2) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {

View File

@@ -12,7 +12,6 @@ export type ShrinkFinding =
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'knownHosts'
| 'portForwardingRules'
| 'groupConfigs';
baseCount: number;
@@ -32,7 +31,6 @@ const CHECKED_ENTITIES = [
'snippets',
'customGroups',
'snippetPackages',
'knownHosts',
'portForwardingRules',
'groupConfigs',
] as const;

40
domain/syncMerge.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mergeSyncPayloads } from "./syncMerge.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
const knownHosts = (n: number): SyncPayload["knownHosts"] =>
Array.from({ length: n }, (_, i) => ({
id: `kh-${i}`,
hostname: `host-${i}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${i}`,
})) as SyncPayload["knownHosts"];
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
const result = mergeSyncPayloads(
payload({ knownHosts: knownHosts(2) }),
payload(),
payload({ knownHosts: knownHosts(3) }),
);
assert.equal("knownHosts" in result.payload, false);
});

View File

@@ -347,7 +347,6 @@ export function mergeSyncPayloads(
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
@@ -365,19 +364,6 @@ export function mergeSyncPayloads(
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
const knownHostSeen = new Set<string>();
const knownHosts = {
...knownHostsRaw,
merged: knownHostsRaw.merged.filter((kh) => {
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
if (knownHostSeen.has(fp)) return false;
knownHostSeen.add(fp);
return true;
}),
};
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
@@ -394,7 +380,7 @@ export function mergeSyncPayloads(
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -437,7 +423,6 @@ export function mergeSyncPayloads(
snippets: snippets.merged,
customGroups,
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
settings,

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
import type { TerminalTheme } from "./models";
const baseTheme: TerminalTheme = {
id: "ui-snow",
name: "Snow",
type: "light",
colors: {
background: "#f1f4f8",
foreground: "#24292f",
cursor: "#0969da",
selection: "#add6ff",
black: "#24292f",
red: "#cf222e",
green: "#116329",
yellow: "#9a6700",
blue: "#0969da",
magenta: "#8250df",
cyan: "#0e7574",
white: "#6e7781",
brightBlack: "#57606a",
brightRed: "#a40e26",
brightGreen: "#1a7f37",
brightYellow: "#7d4e00",
brightBlue: "#218bff",
brightMagenta: "#a475f9",
brightCyan: "#0c7875",
brightWhite: "#8c959f",
},
};
test("applies a custom accent to terminal cursor and selection colors", () => {
const accented = applyCustomAccentToTerminalTheme(baseTheme, "custom", "160 70% 40%");
assert.notEqual(accented, baseTheme);
assert.equal(accented.colors.cursor, "#1fad7e");
assert.equal(accented.colors.selection, "#b1f1dc");
assert.equal(baseTheme.colors.cursor, "#0969da");
assert.equal(baseTheme.colors.selection, "#add6ff");
});
test("keeps terminal theme unchanged without a valid custom accent", () => {
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
});

View File

@@ -1,4 +1,4 @@
import { Host } from './models';
import { Host, TerminalTheme } from './models';
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -69,6 +69,95 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
type ParsedHslToken = {
hue: number;
saturation: number;
lightness: number;
};
const parseHslToken = (value: string): ParsedHslToken | null => {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
if (!match) return null;
const hue = Number(match[1]);
const saturation = Number(match[2]);
const lightness = Number(match[3]);
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
if (saturation < 0 || saturation > 100 || lightness < 0 || lightness > 100) return null;
return {
hue: ((hue % 360) + 360) % 360,
saturation,
lightness,
};
};
const toHexChannel = (value: number): string =>
Math.round(Math.max(0, Math.min(255, value)))
.toString(16)
.padStart(2, '0');
const hslToHex = ({ hue, saturation, lightness }: ParsedHslToken): string => {
const s = saturation / 100;
const l = lightness / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = hue / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
let r = 0;
let g = 0;
let b = 0;
if (hp < 1) {
r = c;
g = x;
} else if (hp < 2) {
r = x;
g = c;
} else if (hp < 3) {
g = c;
b = x;
} else if (hp < 4) {
g = x;
b = c;
} else if (hp < 5) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const m = l - c / 2;
return `#${toHexChannel((r + m) * 255)}${toHexChannel((g + m) * 255)}${toHexChannel((b + m) * 255)}`;
};
const terminalSelectionFromAccent = (accent: ParsedHslToken, type: TerminalTheme['type']): ParsedHslToken => ({
...accent,
lightness: type === 'dark'
? Math.max(18, Math.min(32, accent.lightness * 0.55))
: Math.max(72, Math.min(88, accent.lightness + 42)),
});
export const applyCustomAccentToTerminalTheme = (
theme: TerminalTheme,
accentMode: 'theme' | 'custom',
customAccent: string,
): TerminalTheme => {
if (accentMode !== 'custom') return theme;
const accent = parseHslToken(customAccent);
if (!accent) return theme;
return {
...theme,
colors: {
...theme.colors,
cursor: hslToHex(accent),
selection: hslToHex(terminalSelectionFromAccent(accent, theme.type)),
},
};
};
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
@@ -86,4 +175,3 @@ export const clearHostFontWeightOverride = (host: Host): Host => ({
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;

View File

@@ -1,3 +1,5 @@
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
/**
* @type {import('electron-builder').Configuration}
*/
@@ -79,7 +81,8 @@ module.exports = {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
extraResources: moshExtraResources('darwin')
},
dmg: {
title: '${productName}',
@@ -105,7 +108,8 @@ module.exports = {
target: 'portable',
arch: ['x64', 'arm64']
}
]
],
extraResources: moshExtraResources('win32')
},
portable: {
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
@@ -125,7 +129,8 @@ module.exports = {
// GNOME launchers or AppImage integrations.
icon: 'public/icon-win.png',
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
category: 'Development',
extraResources: moshExtraResources('linux')
},
deb: {
// Use gzip instead of default xz(lzma) for better compatibility with

View File

@@ -0,0 +1,130 @@
function toNonEmptyString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeConfigOptionValue(value) {
const id = toNonEmptyString(value?.value ?? value?.id);
if (!id) return null;
return {
id,
name: toNonEmptyString(value?.name ?? value?.displayName) || id,
description: toNonEmptyString(value?.description) || undefined,
};
}
function flattenConfigOptionValues(values) {
if (!Array.isArray(values)) return [];
const flattened = [];
for (const value of values) {
const nestedValues = Array.isArray(value?.options)
? value.options
: Array.isArray(value?.items)
? value.items
: Array.isArray(value?.children)
? value.children
: null;
if (nestedValues) {
flattened.push(...flattenConfigOptionValues(nestedValues));
continue;
}
const normalized = normalizeConfigOptionValue(value);
if (normalized) {
flattened.push(normalized);
}
}
return flattened;
}
function findConfigOption(configOptions, category, fallbackIds = []) {
if (!Array.isArray(configOptions)) return null;
return configOptions.find((option) => {
const optionCategory = toNonEmptyString(option?.category);
const optionId = toNonEmptyString(option?.id);
return optionCategory === category || (optionId && fallbackIds.includes(optionId));
}) || null;
}
function normalizeConfigOptionsModels(sessionInfo) {
const configOptions = Array.isArray(sessionInfo?.configOptions)
? sessionInfo.configOptions
: [];
const modelOption = findConfigOption(configOptions, "model", ["model"]);
const reasoningOption = findConfigOption(configOptions, "thought_level", [
"reasoning_effort",
"reasoning",
"thought_level",
]);
const modelValues = flattenConfigOptionValues(modelOption?.options);
if (modelValues.length === 0) return null;
const configuredThinkingLevels = flattenConfigOptionValues(reasoningOption?.options)
.map((option) => option.id);
const availableModelIds = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
.map((modelInfo) => toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id))
.filter(Boolean)
: [];
const availableModelIdSet = new Set(availableModelIds);
const thinkingLevelsByModelId = new Map();
for (const model of modelValues) {
const validThinkingLevels = configuredThinkingLevels.length > 0
? configuredThinkingLevels.filter((level) => availableModelIdSet.has(`${model.id}/${level}`))
: availableModelIds
.filter((modelId) => modelId.startsWith(`${model.id}/`))
.map((modelId) => modelId.slice(model.id.length + 1))
.filter(Boolean);
if (validThinkingLevels.length > 0) {
thinkingLevelsByModelId.set(model.id, validThinkingLevels);
}
}
const currentFromModels = toNonEmptyString(sessionInfo?.models?.currentModelId);
const currentModel = toNonEmptyString(modelOption?.currentValue);
const currentThinking = toNonEmptyString(reasoningOption?.currentValue);
let currentModelId = currentFromModels;
if (currentModel) {
if (currentThinking && availableModelIdSet.has(`${currentModel}/${currentThinking}`)) {
currentModelId = `${currentModel}/${currentThinking}`;
} else if (!currentModelId || (currentModelId !== currentModel && !currentModelId.startsWith(`${currentModel}/`))) {
currentModelId = currentModel;
}
}
return {
currentModelId: currentModelId || null,
models: modelValues.map((model) => {
const modelThinkingLevels = thinkingLevelsByModelId.get(model.id);
return {
...model,
...(modelThinkingLevels ? { thinkingLevels: modelThinkingLevels } : {}),
};
}),
};
}
function normalizeLegacySessionModels(sessionInfo) {
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
return {
currentModelId: toNonEmptyString(sessionInfo?.models?.currentModelId),
models: availableModels.map((modelInfo) => {
const id = toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id);
if (!id) return null;
return {
id,
name: toNonEmptyString(modelInfo?.name ?? modelInfo?.displayName) || id,
description: toNonEmptyString(modelInfo?.description) || undefined,
};
}).filter(Boolean),
};
}
function normalizeAcpSessionModels(sessionInfo) {
return normalizeConfigOptionsModels(sessionInfo) || normalizeLegacySessionModels(sessionInfo);
}
module.exports = {
normalizeAcpSessionModels,
};

View File

@@ -0,0 +1,158 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { normalizeAcpSessionModels } = require("./acpModels.cjs");
test("normalizeAcpSessionModels uses ACP config options for model and reasoning selectors", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.5/xhigh",
availableModels: [
{ modelId: "gpt-5.5/low", name: "GPT 5.5 Low" },
{ modelId: "gpt-5.5/medium", name: "GPT 5.5 Medium" },
{ modelId: "gpt-5.5/high", name: "GPT 5.5 High" },
{ modelId: "gpt-5.5/xhigh", name: "GPT 5.5 Extra High" },
{ modelId: "gpt-5.1-codex-mini/medium", name: "Codex Mini Medium" },
{ modelId: "gpt-5.1-codex-mini/high", name: "Codex Mini High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.5",
options: [
{ value: "gpt-5.5", name: "GPT 5.5" },
{ value: "gpt-5.1-codex-mini", name: "Codex Mini", description: "Fast" },
],
},
{
id: "reasoning_effort",
category: "thought_level",
currentValue: "xhigh",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
{ value: "xhigh", name: "Extra High" },
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.5/xhigh");
assert.deepEqual(result.models, [
{
id: "gpt-5.5",
name: "GPT 5.5",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
},
{
id: "gpt-5.1-codex-mini",
name: "Codex Mini",
description: "Fast",
thinkingLevels: ["medium", "high"],
},
]);
});
test("normalizeAcpSessionModels flattens grouped ACP config option values", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.4/high",
availableModels: [
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.4",
options: [
{
name: "Frontier",
options: [
{ value: "gpt-5.4", name: "GPT 5.4" },
],
},
],
},
{
id: "reasoning_effort",
category: "thought_level",
currentValue: "high",
options: [
{
name: "Reasoning",
options: [
{ value: "low", name: "Low" },
{ value: "high", name: "High" },
],
},
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.4/high");
assert.deepEqual(result.models, [
{
id: "gpt-5.4",
name: "GPT 5.4",
description: undefined,
thinkingLevels: ["high"],
},
]);
});
test("normalizeAcpSessionModels infers thinking levels from available model ids", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.4/high",
availableModels: [
{ modelId: "gpt-5.4/low", name: "GPT 5.4 Low" },
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.4",
options: [
{ value: "gpt-5.4", name: "GPT 5.4" },
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.4/high");
assert.deepEqual(result.models, [
{
id: "gpt-5.4",
name: "GPT 5.4",
description: undefined,
thinkingLevels: ["low", "high"],
},
]);
});
test("normalizeAcpSessionModels falls back to legacy ACP models when config options are absent", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", displayName: "Opus 4.5" },
{ modelId: "claude-sonnet-4-5", name: "Sonnet 4.5" },
],
},
});
assert.equal(result.currentModelId, "claude-opus-4-5");
assert.deepEqual(result.models, [
{ id: "claude-opus-4-5", name: "Opus 4.5", description: undefined },
{ id: "claude-sonnet-4-5", name: "Sonnet 4.5", description: undefined },
]);
});

View File

@@ -353,16 +353,57 @@ function normalizeCodexIntegrationState(rawOutput) {
// ── Error helpers ──
function safeJsonStringify(value) {
const seen = new WeakSet();
try {
return JSON.stringify(value, (_key, nestedValue) => {
if (typeof nestedValue !== "object" || nestedValue === null) {
return nestedValue;
}
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
return nestedValue;
});
} catch {
return null;
}
}
function stringifyErrorValue(value, seen = new WeakSet()) {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Error) return value.message || value.name || String(value);
if (typeof value !== "object") return String(value);
if (seen.has(value)) return "[Circular error]";
seen.add(value);
const candidates = [
value?.data?.message,
value?.data?.error,
value?.errorText,
value?.message,
value?.error,
value?.cause,
value?.data,
];
for (const candidate of candidates) {
const message = stringifyErrorValue(candidate, seen).trim();
if (message && message !== "{}") {
return message;
}
}
return safeJsonStringify(value) || String(value);
}
function extractCodexError(error) {
const message =
error?.data?.message ||
error?.errorText ||
error?.message ||
error?.error ||
String(error);
const code = error?.data?.code || error?.code;
const message = stringifyErrorValue(error) || "Unknown Codex error";
const code = error?.data?.code || error?.code || error?.error?.code || error?.data?.error?.code;
return {
message: typeof message === "string" ? message : String(message),
message,
code: typeof code === "string" ? code : undefined,
};
}

View File

@@ -0,0 +1,37 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractCodexError } = require("./codexHelpers.cjs");
test("extractCodexError preserves nested error object messages", () => {
const normalized = extractCodexError({
error: {
code: "model_not_found",
message: "Model gpt-test is not available",
},
});
assert.deepEqual(normalized, {
message: "Model gpt-test is not available",
code: "model_not_found",
});
});
test("extractCodexError stringifies unknown object errors instead of [object Object]", () => {
const normalized = extractCodexError({
status: 400,
detail: "Bad request",
});
assert.equal(normalized.message, '{"status":400,"detail":"Bad request"}');
assert.equal(normalized.code, undefined);
});
test("extractCodexError handles circular structured errors", () => {
const error = { status: 500 };
error.self = error;
const normalized = extractCodexError(error);
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
});

View File

@@ -202,6 +202,15 @@ function toUnpackedAsarPath(filePath) {
return filePath;
}
function isPlausibleCliVersionOutput(value) {
const line = stripAnsi(String(value || "")).trim().split(/\r?\n/)[0]?.trim() || "";
if (!line) return false;
if (/^(?:file|node):\/\//i.test(line)) return false;
if (/^\s*at\s+/i.test(line)) return false;
if (/\b(?:Error|TypeError|ReferenceError|SyntaxError|ERR_[A-Z_]+)\b/.test(line)) return false;
return /(?:^|[^\d])v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?(?:$|[^\d])/.test(line);
}
// ── Shell environment (cached) ──
let _cachedShellEnv = null;
@@ -374,6 +383,7 @@ module.exports = {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
isPlausibleCliVersionOutput,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,

View File

@@ -5,6 +5,7 @@ const {
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -65,6 +66,16 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
assert.equal(isPlausibleCliVersionOutput("file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95"), false);
assert.equal(isPlausibleCliVersionOutput("TypeError: Cannot read properties of undefined"), false);
assert.equal(isPlausibleCliVersionOutput(" at runCli (cli.js:10:1)"), false);
assert.equal(isPlausibleCliVersionOutput("permission denied"), false);
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};

View File

@@ -29,6 +29,7 @@ const {
shouldUseShellForCommand,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
isPlausibleCliVersionOutput,
getShellEnv,
getFreshIdlePrompt,
invalidateShellEnvCache,
@@ -54,6 +55,7 @@ const {
getCodexValidationCache,
setCodexValidationCache,
} = require("./ai/codexHelpers.cjs");
const { normalizeAcpSessionModels } = require("./ai/acpModels.cjs");
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
const NETCATTY_TOOL_SKILL_PATH = toUnpackedAsarPath(
@@ -1428,6 +1430,67 @@ function registerHandlers(ipcMain) {
});
}
function getCommandOutput(result) {
return [result?.stdout, result?.stderr]
.filter((chunk) => typeof chunk === "string" && chunk.length > 0)
.join("\n")
.trim();
}
function getFirstCommandOutputLine(result) {
return getCommandOutput(result).split(/\r?\n/)[0] || "";
}
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env });
return {
launched: true,
exitCode: result.exitCode,
output: getCommandOutput(result),
version: getFirstCommandOutputLine(result),
};
} catch {
return {
launched: false,
exitCode: null,
output: "",
version: "",
};
}
}
function isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) {
return (
command === "codex" &&
usesAcpFallback &&
path.basename(resolvedPath || "").toLowerCase().startsWith("codex-acp")
);
}
function isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
if (!isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) || !probe?.launched) {
return false;
}
const output = String(probe.output || "").toLowerCase();
const hasCodexAcpUsage = /\busage:\s*codex-acp(?:\.exe)?\s+\[options\]/.test(output);
const rejectedVersionFlag =
/(unexpected|unrecognized|unknown)\s+(argument|option|flag)\s+['"]?--version['"]?/.test(output) ||
/['"]?--version['"]?\s+(found|is\s+)?(unexpected|unrecognized|unknown)/.test(output);
return hasCodexAcpUsage && rejectedVersionFlag;
}
function isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe) {
return command === "claude" && usesAcpFallback && probe?.launched && probe.exitCode === 0;
}
function isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
return (
isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) ||
isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe)
);
}
async function runCodexCli(args, options) {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
@@ -1676,11 +1739,17 @@ function registerHandlers(ipcMain) {
// resolveCodexAcpBinaryPath returns a plain string.
let versionCommand = null;
let versionPrependArgs = [];
if (!resolvedPath && agent.resolveAcp) {
let usesAcpFallback = false;
const tryResolveAcpFallback = () => {
if (!agent.resolveAcp) return false;
const result = agent.resolveAcp(shellEnv, electronModule);
if (typeof result === "string") {
if (result && result !== agent.acpCommand && existsSync(result)) {
resolvedPath = result;
versionCommand = null;
versionPrependArgs = [];
usesAcpFallback = true;
return true;
}
} else if (result?.command) {
// On Windows the command may be `node` with the script in prependArgs.
@@ -1690,39 +1759,62 @@ function registerHandlers(ipcMain) {
const displayPath = scriptPath || result.command;
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
resolvedPath = displayPath;
usesAcpFallback = true;
if (scriptPath) {
versionCommand = result.command;
versionPrependArgs = result.prependArgs;
} else {
versionCommand = null;
versionPrependArgs = [];
}
return true;
}
}
return false;
};
if (!resolvedPath) {
tryResolveAcpFallback();
}
if (!resolvedPath || seenPaths.has(resolvedPath)) {
continue;
}
let version = "";
try {
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
const probeCmd = versionCommand || resolvedPath;
const probeArgs = [...versionPrependArgs, "--version"];
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
// --version failed: not a valid CLI executable (e.g. .app bundle)
continue;
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
let version = probe.version;
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
let hasUsableAcpFallback = isAcpFallbackProbeUsable(
agent.command,
usesAcpFallback,
resolvedPath,
probe,
);
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && agent.command === "codex") {
const previousPath = resolvedPath;
if (tryResolveAcpFallback() && resolvedPath !== previousPath && !seenPaths.has(resolvedPath)) {
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
version = probe.version;
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
hasUsableAcpFallback = isAcpFallbackProbeUsable(
agent.command,
usesAcpFallback,
resolvedPath,
probe,
);
}
}
if (!version) continue;
if (!hasPlausibleVersion && !hasUsableAcpFallback) continue;
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
version: hasPlausibleVersion ? version : "Bundled ACP",
available: true,
});
seenPaths.add(resolvedPath);
@@ -1736,6 +1828,50 @@ function registerHandlers(ipcMain) {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const shellEnv = await getShellEnv();
let resolvedPath = null;
let versionCommand = null;
let versionPrependArgs = [];
let usesAcpFallback = false;
const getBundledAcpFallback = () => {
if (command === "codex") {
const acpPath = resolveCodexAcpBinaryPath(shellEnv, electronModule);
if (acpPath && acpPath !== "codex-acp" && existsSync(acpPath)) {
return {
displayPath: acpPath,
command: null,
prependArgs: [],
};
}
return null;
}
if (command === "claude") {
const acpPath = resolveClaudeAcpBinaryPath(shellEnv, electronModule);
const scriptPath = acpPath?.prependArgs?.[0];
const displayPath = scriptPath || acpPath?.command;
if (displayPath && displayPath !== "claude-agent-acp" && existsSync(displayPath)) {
return {
displayPath,
command: scriptPath ? acpPath.command : null,
prependArgs: scriptPath ? acpPath.prependArgs : [],
};
}
}
return null;
};
const resolveBundledAcpFallback = () => {
const fallback = getBundledAcpFallback();
if (!fallback) return false;
if (resolvedPath === fallback.displayPath) {
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
return true;
}
resolvedPath = fallback.displayPath;
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
return true;
};
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
@@ -1745,25 +1881,38 @@ function registerHandlers(ipcMain) {
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
if (!resolvedPath) {
resolveBundledAcpFallback();
} else {
const fallback = getBundledAcpFallback();
if (fallback && resolvedPath === fallback.displayPath) {
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
}
}
if (!resolvedPath) {
return { path: null, version: null, available: false };
}
let version = "";
try {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
// --version failed: not a valid CLI executable
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
let version = probe.version;
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
let hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && command === "codex") {
if (resolveBundledAcpFallback()) {
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
version = probe.version;
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
}
}
if (!hasPlausibleVersion && !hasUsableAcpFallback) {
return { path: resolvedPath, version: null, available: false };
}
if (!version) {
return { path: resolvedPath, version: null, available: false };
}
return { path: resolvedPath, version, available: true };
return { path: resolvedPath, version: hasPlausibleVersion ? version : "Bundled ACP", available: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
@@ -2269,15 +2418,13 @@ function registerHandlers(ipcMain) {
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
const modelCatalog = normalizeAcpSessionModels(sessionInfo);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
currentModelId: modelCatalog.currentModelId || null,
availableModelIds: modelCatalog.models.map((modelInfo) => modelInfo.id),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
@@ -2285,16 +2432,13 @@ function registerHandlers(ipcMain) {
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
currentModelId: modelCatalog.currentModelId || null,
models: modelCatalog.models,
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
const normalized = extractCodexError(err);
console.error("[ACP] Failed to list models:", normalized.message);
return { ok: false, error: normalized.message };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");

View File

@@ -1,6 +1,9 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
function createIpcMainStub() {
const handlers = new Map();
@@ -27,6 +30,40 @@ function createEmptyStreamResult() {
};
}
function writeFakeCodexAcpUsage(filePath) {
if (process.platform === "win32") {
fs.writeFileSync(
filePath,
"@echo off\r\necho error: unexpected argument '--version' found\r\necho.\r\necho Usage: codex-acp [OPTIONS]\r\nexit /b 2\r\n",
"utf8",
);
return;
}
fs.writeFileSync(
filePath,
"#!/bin/sh\necho \"error: unexpected argument '--version' found\"\necho\necho 'Usage: codex-acp [OPTIONS]'\nexit 2\n",
"utf8",
);
fs.chmodSync(filePath, 0o755);
}
function writeFakeCodexAcpLoaderError(filePath) {
if (process.platform === "win32") {
fs.writeFileSync(
filePath,
"@echo off\r\necho codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file\r\nexit /b 127\r\n",
"utf8",
);
return;
}
fs.writeFileSync(
filePath,
"#!/bin/sh\necho 'codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file'\nexit 127\n",
"utf8",
);
fs.chmodSync(filePath, 0o755);
}
function loadBridgeWithMocks(options = {}) {
const streamCalls = [];
const safeSendCalls = [];
@@ -74,10 +111,23 @@ function loadBridgeWithMocks(options = {}) {
},
"./ai/shellUtils.cjs": {
stripAnsi: (value) => value,
normalizeCliPathForPlatform: (value) => value,
normalizeCliPathForPlatform: (...args) =>
typeof options.normalizeCliPathForPlatform === "function"
? options.normalizeCliPathForPlatform(...args)
: args[0],
shouldUseShellForCommand: () => false,
resolveCliFromPath: () => null,
resolveClaudeAcpBinaryPath: () => null,
isPlausibleCliVersionOutput: (value) =>
typeof options.isPlausibleCliVersionOutput === "function"
? options.isPlausibleCliVersionOutput(value)
: true,
resolveCliFromPath: (...args) =>
typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveClaudeAcpBinaryPath: (...args) =>
typeof options.resolveClaudeAcpBinaryPath === "function"
? options.resolveClaudeAcpBinaryPath(...args)
: null,
getShellEnv: async () => ({}),
invalidateShellEnvCache() {},
serializeStreamChunk: (chunk) => chunk,
@@ -85,7 +135,10 @@ function loadBridgeWithMocks(options = {}) {
},
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
resolveCodexAcpBinaryPath: () => null,
resolveCodexAcpBinaryPath: (...args) =>
typeof options.resolveCodexAcpBinaryPath === "function"
? options.resolveCodexAcpBinaryPath(...args)
: null,
appendCodexLoginOutput() {},
toCodexLoginSessionResponse: () => ({}),
getActiveCodexLoginSession: () => null,
@@ -199,6 +252,582 @@ function loadBridgeWithMocks(options = {}) {
}
}
test("discovers bundled Codex ACP fallback when --version prints usage", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("discovers bundled Codex ACP fallback when PATH Codex shim is broken", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("discovers bundled Codex ACP fallback when PATH Codex exits nonzero", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-exit-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("does not discover bundled Codex ACP fallback when the fallback cannot run", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-bad-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
fs.mkdirSync(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("does not discover bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-loader-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpLoaderError(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("resolve-cli accepts bundled Codex ACP fallback when --version prints usage", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stored-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
normalizeCliPathForPlatform: () => codexAcpPath,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler(
{ sender: { id: 1 } },
{ command: "codex", customPath: codexAcpPath },
);
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
normalizeCliPathForPlatform: () => null,
resolveCliFromPath: () => null,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler(
{ sender: { id: 1 } },
{ command: "codex", customPath: "/stale/bin/codex" },
);
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when PATH Codex shim is broken", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when PATH Codex exits nonzero", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-exit-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli rejects bundled Codex ACP fallback when the fallback cannot run", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-bad-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
fs.mkdirSync(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: null,
available: false,
});
} finally {
restore();
}
});
test("resolve-cli rejects bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-loader-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpLoaderError(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: null,
available: false,
});
} finally {
restore();
}
});
test("discovers bundled Claude ACP fallback when the version probe is silent", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-discover-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "claude");
assert.equal(agents[0].path, scriptPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("resolve-cli accepts stored bundled Claude ACP script path via its launcher", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-stored-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
normalizeCliPathForPlatform: () => scriptPath,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: scriptPath });
assert.deepEqual(result, {
path: scriptPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
const ipcMain = createIpcMainStub();

View File

@@ -0,0 +1,344 @@
/**
* Node-side replacement for the upstream Mosh Perl wrapper.
*
* The upstream `mosh` script is a tiny orchestrator: it execs `ssh` to
* run `mosh-server new` on the remote host, scrapes the
* "MOSH CONNECT <port> <key>" line from the SSH stream, then execs
* `mosh-client` locally with that port/key. This module does the same
* thing in JS so we no longer need a Perl interpreter on the user's
* machine — and so we can drive a bundled `mosh-client` even on
* Windows (which has no Perl wrapper).
*
* Flow (driven by terminalBridge.startMoshSession):
* 1. spawn `ssh -t [-p port] [user@]host -- mosh-server new -s ...`
* inside a node-pty, sized to the renderer's cols/rows so password
* / 2FA prompts render natively.
* 2. forward every byte from the ssh PTY to the renderer (parsing
* simultaneously via parseMoshConnect).
* 3. when `MOSH CONNECT <port> <key>` is detected, kill the ssh PTY,
* spawn `mosh-client <ip> <port>` in a fresh node-pty with
* MOSH_KEY=<key> in the environment, and let the bridge swap that
* new PTY into the existing session.
*
* On every supported platform the module relies on the system `ssh`
* binary for the SSH bootstrap (Windows 10 1809+ ships OpenSSH by
* default, macOS / Linux have it everywhere). That keeps key / agent /
* config handling identical to what the user already has working with
* `ssh` — no need to reimplement OpenSSH features in this codebase.
*/
const path = require("node:path");
const net = require("node:net");
const MOSH_CONNECT_RE = /MOSH CONNECT[ \t]+(\d{1,5})[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/;
const MOSH_IP_RE = /MOSH IP[ \t]+(\S+)[ \t]*/;
const PROTOCOL_MARKERS = ["MOSH CONNECT", "MOSH IP"];
function shellQuote(value) {
const text = String(value);
return `'${text.replace(/'/g, `'\\''`)}'`;
}
function validMoshKey(key) {
return key.length === 22 || (key.length === 24 && key.endsWith("=="));
}
function parseConnectLine(line) {
const m = MOSH_CONNECT_RE.exec(line);
if (!m) return null;
const port = Number(m[1]);
const key = m[2];
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
if (!validMoshKey(key)) return null;
return {
port,
key,
matchStartOffset: m.index,
matchEndOffset: m.index + m[0].length,
};
}
function parseMoshIpLine(line) {
const m = MOSH_IP_RE.exec(line);
if (!m) return null;
const host = m[1];
return net.isIP(host) ? host : null;
}
function forEachCompleteLine(text, visit) {
const lineRe = /([^\r\n]*)(\r\n|\r|\n)/g;
let m;
while ((m = lineRe.exec(text)) !== null) {
if (visit({
line: m[1],
newline: m[2],
startIndex: m.index,
endIndex: lineRe.lastIndex,
}) === false) {
break;
}
}
}
function findMoshConnect(text) {
let found = null;
forEachCompleteLine(text, ({ line, newline, startIndex, endIndex }) => {
const parsed = parseConnectLine(line);
if (!parsed) return;
found = {
port: parsed.port,
key: parsed.key,
matchStartIndex: startIndex + parsed.matchStartOffset,
matchEndIndex: endIndex,
visiblePrefix: line.slice(0, parsed.matchStartOffset),
visibleSuffix: line.slice(parsed.matchEndOffset) + newline,
};
return false;
});
return found;
}
function potentialProtocolStart(text) {
if (!text) return -1;
let best = -1;
for (const marker of PROTOCOL_MARKERS) {
const full = text.indexOf(marker);
if (full !== -1) {
best = best === -1 ? full : Math.min(best, full);
}
for (let len = Math.min(marker.length - 1, text.length); len > 0; len -= 1) {
if (marker.startsWith(text.slice(text.length - len))) {
const pos = text.length - len;
best = best === -1 ? pos : Math.min(best, pos);
break;
}
}
}
return best;
}
function buildMoshServerCommand(moshServerPath) {
const trimmed = typeof moshServerPath === "string" ? moshServerPath.trim() : "";
if (!trimmed) return "mosh-server new -s";
return `${shellQuote(trimmed)} new -s`;
}
/**
* Parse a buffer of bytes from the SSH PTY for a MOSH CONNECT line.
*
* Returns { port: number, key: string, matchEndIndex: number } when the
* marker is found, otherwise null. matchEndIndex is the byte offset
* immediately after the matched line in the *current* chunk so callers
* can tell what to strip from the renderer-visible stream (since the
* line is internal protocol, not a user-visible prompt).
*
* The parser is deliberately stateless: callers should keep a small
* trailing window (≤ 4096 bytes) of unmatched data so the marker isn't
* lost when it spans chunk boundaries.
*/
function parseMoshConnect(buffer) {
const text = Buffer.isBuffer(buffer) ? buffer.toString("utf8") : String(buffer);
const found = findMoshConnect(text);
if (!found) return null;
return { port: found.port, key: found.key, matchEndIndex: found.matchEndIndex };
}
/**
* Build the argv for the ssh bootstrap command.
*
* ssh -t [-p port] [user@]host -- LC_ALL=... mosh-server new -s [...]
*
* `-t` allocates a remote TTY so password / 2FA prompts work; `--`
* separates ssh's options from the remote command we want it to run.
* The remote command runs `mosh-server new` and exits, with the magic
* line emitted to stdout.
*
* @param {object} opts
* @param {string} opts.host — hostname or IP
* @param {number} [opts.port] — ssh port (omit for default 22)
* @param {string} [opts.username] — ssh user (defaults to ssh's choice)
* @param {string} [opts.lang] — LC_ALL override for mosh-server
* @param {string} [opts.moshServer]— remote command (default "mosh-server new")
* @param {string[]} [opts.sshArgs] — extra args passed to ssh (e.g. -i path)
* @returns {{ command: string, args: string[] }}
*/
function buildSshHandshakeCommand(opts) {
if (!opts || !opts.host) throw new Error("buildSshHandshakeCommand: host is required");
// No -t / -tt by default: this command only runs `mosh-server new`
// and immediately exits; mosh-server itself doesn't need a TTY for
// the `new` subcommand (it prints MOSH CONNECT to stdout and forks
// into the background). Forcing a TTY would require -tt and break
// BatchMode-friendly stdout capture.
const args = [];
if (opts.port && Number(opts.port) !== 22) {
args.push("-p", String(opts.port));
}
if (Array.isArray(opts.sshArgs)) {
args.push(...opts.sshArgs);
}
const target = opts.username ? `${opts.username}@${opts.host}` : opts.host;
args.push(target);
args.push("--");
// Quote the remote command minimally — ssh runs it through the
// remote shell so simple "command arg arg" works without shell
// metacharacters from us. mosh-server prints the magic CONNECT line
// and otherwise stays silent.
const lang = opts.lang || "en_US.UTF-8";
const moshServer = opts.moshServer || "mosh-server new -s";
args.push(`LC_ALL=${shellQuote(lang)} ${moshServer}`);
return { command: "ssh", args };
}
/**
* Build the argv for the local mosh-client invocation once the
* handshake produced an ip + port + key.
*
* mosh-client <ip> <port> (with MOSH_KEY in env)
*
* `mosh-server` listens on UDP at the IP/port pair it announced. By
* convention, the IP is derived from the "MOSH IP" line emitted before
* MOSH CONNECT, but most servers omit it and the client just uses the
* SSH-resolved hostname / IP. We default to the original hostname when
* no MOSH IP override is available.
*/
function buildMoshClientCommand({ moshClientPath, host, port }) {
if (!moshClientPath) throw new Error("buildMoshClientCommand: moshClientPath is required");
if (!host) throw new Error("buildMoshClientCommand: host is required");
if (!port || port <= 0) throw new Error("buildMoshClientCommand: port must be > 0");
return { command: moshClientPath, args: [host, String(port)] };
}
/**
* Lightweight stream sniffer: hands chunks in, emits MOSH CONNECT
* details + the byte ranges that should be hidden from the user-
* visible stream.
*
* Usage:
* const sniffer = createMoshConnectSniffer();
* for each chunk: const { visible, parsed } = sniffer.feed(chunk);
* send `visible` to renderer; if `parsed`, switch to mosh-client.
*
* Once a parse hits, every subsequent chunk passes through unchanged
* (defensive: the bridge will tear down the SSH PTY immediately after
* the parse so further chunks are unlikely, but we don't want to leak
* partial copies of MOSH CONNECT lines if we somehow get more bytes).
*
* The sniffer keeps a trailing window of unmatched bytes (RING_SIZE) so
* it can detect MOSH CONNECT spanning chunk boundaries.
*/
function createMoshConnectSniffer() {
const RING_SIZE = 4096;
const MAX_PROTOCOL_LINE = 512;
let pending = "";
let parsed = null;
let moshHost = null;
return {
feed(chunk) {
if (parsed) return { visible: chunk, parsed: null };
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
pending += text;
let visibleText = "";
let consumed = 0;
forEachCompleteLine(pending, ({ line, newline, startIndex, endIndex }) => {
if (startIndex > consumed) {
visibleText += pending.slice(consumed, startIndex);
}
const ip = parseMoshIpLine(line);
if (ip) {
moshHost = ip;
consumed = endIndex;
return;
}
const connect = parseConnectLine(line);
if (connect) {
parsed = { port: connect.port, key: connect.key };
if (moshHost) parsed.host = moshHost;
visibleText += line.slice(0, connect.matchStartOffset);
const suffix = line.slice(connect.matchEndOffset);
if (suffix) visibleText += suffix + newline;
consumed = endIndex;
return false;
}
visibleText += line + newline;
consumed = endIndex;
});
if (parsed) {
visibleText += pending.slice(consumed);
pending = "";
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
return { visible, parsed };
}
pending = pending.slice(consumed);
const holdIndex = potentialProtocolStart(pending);
if (holdIndex === -1) {
visibleText += pending;
pending = "";
} else {
visibleText += pending.slice(0, holdIndex);
pending = pending.slice(holdIndex);
if (pending.length > MAX_PROTOCOL_LINE) {
visibleText += pending;
pending = "";
}
}
if (pending.length > RING_SIZE) {
const overflow = pending.length - RING_SIZE;
visibleText += pending.slice(0, overflow);
pending = pending.slice(overflow);
}
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
return { visible, parsed };
},
isParsed() { return parsed !== null; },
};
}
/**
* Assemble the env that `mosh-client` will see. MOSH_KEY is the secret
* shared with mosh-server, and we preserve TERM + LANG so the local
* terminfo lookups pick the right entry.
*/
function buildMoshClientEnv({ baseEnv, key, lang }) {
const env = { ...(baseEnv || {}), MOSH_KEY: key };
if (lang && !env.LANG) env.LANG = lang;
if (!env.TERM) env.TERM = "xterm-256color";
return env;
}
/**
* Resolve the absolute path of the system `ssh` binary. On Windows we
* try the in-box OpenSSH location first because PATH may not list
* it inside the Electron child env.
*/
function resolveSshExecutable({ findExecutable, fileExists, platform = process.platform }) {
const fromPath = findExecutable("ssh");
if (fromPath && fromPath !== "ssh" && fileExists(fromPath)) return fromPath;
if (platform === "win32") {
const sysRoot = process.env.SystemRoot || process.env.SYSTEMROOT || "C:\\Windows";
// Build with the win32-flavored path module so the result is
// back-slash-joined regardless of the host platform we're running
// the lookup from (relevant for cross-platform unit tests).
const inbox = path.win32.join(sysRoot, "System32", "OpenSSH", "ssh.exe");
if (fileExists(inbox)) return inbox;
}
return null;
}
module.exports = {
parseMoshConnect,
buildSshHandshakeCommand,
buildMoshServerCommand,
buildMoshClientCommand,
createMoshConnectSniffer,
buildMoshClientEnv,
resolveSshExecutable,
};

View File

@@ -0,0 +1,229 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseMoshConnect,
buildSshHandshakeCommand,
buildMoshServerCommand,
buildMoshClientCommand,
createMoshConnectSniffer,
buildMoshClientEnv,
resolveSshExecutable,
} = require("./moshHandshake.cjs");
test("parseMoshConnect captures port and key from a typical mosh-server line", () => {
const line = "Welcome\r\nMOSH CONNECT 60001 ABCDEFGHIJKLMNOPQRSTUV==\r\n";
const got = parseMoshConnect(line);
assert.deepEqual(got && { port: got.port, key: got.key }, {
port: 60001,
key: "ABCDEFGHIJKLMNOPQRSTUV==",
});
});
test("parseMoshConnect accepts unpadded base64 keys (length 22)", () => {
const line = "MOSH CONNECT 60005 abcdefghijklmnopqrstuv\n";
const got = parseMoshConnect(line);
assert.equal(got && got.port, 60005);
assert.equal(got && got.key.length, 22);
});
test("parseMoshConnect rejects out-of-range ports", () => {
assert.equal(parseMoshConnect("MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
assert.equal(parseMoshConnect("MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
});
test("parseMoshConnect rejects implausibly short keys (substring noise)", () => {
assert.equal(parseMoshConnect("MOSH CONNECT 60000 abc\n"), null);
});
test("parseMoshConnect handles a Buffer chunk", () => {
const buf = Buffer.from("garbage MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\n");
const got = parseMoshConnect(buf);
assert.equal(got && got.port, 60010);
});
test("buildSshHandshakeCommand omits -t and uses default port", () => {
const got = buildSshHandshakeCommand({ host: "example.com", username: "alice" });
assert.equal(got.command, "ssh");
assert.deepEqual(got.args, [
"alice@example.com",
"--",
"LC_ALL='en_US.UTF-8' mosh-server new -s",
]);
});
test("buildSshHandshakeCommand passes a non-default port via -p", () => {
const got = buildSshHandshakeCommand({ host: "example.com", port: 2222 });
assert.deepEqual(got.args.slice(0, 2), ["-p", "2222"]);
});
test("buildSshHandshakeCommand interpolates lang and moshServer overrides", () => {
const got = buildSshHandshakeCommand({
host: "h",
lang: "zh_CN.UTF-8",
moshServer: "/opt/mosh/bin/mosh-server new -s -c 256",
});
assert.equal(got.args.at(-1), "LC_ALL='zh_CN.UTF-8' /opt/mosh/bin/mosh-server new -s -c 256");
});
test("buildSshHandshakeCommand shell-quotes lang values", () => {
const got = buildSshHandshakeCommand({
host: "h",
lang: "C; touch /tmp/netcatty-owned",
});
assert.equal(got.args.at(-1), "LC_ALL='C; touch /tmp/netcatty-owned' mosh-server new -s");
});
test("buildMoshServerCommand treats custom server input as a path", () => {
assert.equal(
buildMoshServerCommand("/opt/Mosh Tools/mosh-server; touch /tmp/nope"),
"'/opt/Mosh Tools/mosh-server; touch /tmp/nope' new -s",
);
});
test("buildSshHandshakeCommand throws when host is missing", () => {
assert.throws(() => buildSshHandshakeCommand({}), /host is required/);
});
test("buildMoshClientCommand wires moshClientPath, host, port", () => {
const got = buildMoshClientCommand({
moshClientPath: "/usr/local/bin/mosh-client",
host: "10.0.0.1",
port: 60001,
});
assert.equal(got.command, "/usr/local/bin/mosh-client");
assert.deepEqual(got.args, ["10.0.0.1", "60001"]);
});
test("buildMoshClientCommand validates inputs", () => {
assert.throws(() => buildMoshClientCommand({ host: "h", port: 1 }), /moshClientPath/);
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", port: 1 }), /host/);
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", host: "h", port: 0 }), /port/);
});
test("createMoshConnectSniffer detects MOSH CONNECT split across chunks", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("login as: alice\r\nlast login: yesterday\r\nMOSH CONNE");
assert.equal(r1.parsed, null);
assert.ok(!String(r1.visible).includes("MOSH CONNE"));
const r2 = sniffer.feed("CT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.ok(!String(r2.visible).includes("MOSH CONNECT"));
assert.ok(!String(r2.visible).includes("ABCDEFGHIJKLMNOPQRSTUV=="));
});
test("createMoshConnectSniffer does not leak a split MOSH key", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("intro\r\nMOSH CONNECT 60002 ABCDEFGHIJ");
assert.equal(r1.parsed, null);
assert.equal(String(r1.visible), "intro\r\n");
const r2 = sniffer.feed("KLMNOPQRSTUV==\r\n");
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.equal(String(r2.visible), "");
});
test("createMoshConnectSniffer passes through prompts without waiting for a newline", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("password:");
assert.equal(r.parsed, null);
assert.equal(String(r.visible), "password:");
});
test("createMoshConnectSniffer ignores invalid MOSH CONNECT lines", () => {
for (const line of [
"MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
"MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
"MOSH CONNECT 60000 short\r\n",
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n",
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUV==oops\r\n",
]) {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed(line);
assert.equal(r.parsed, null, line);
}
});
test("createMoshConnectSniffer captures MOSH IP without showing protocol lines", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("welcome\r\nMOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==", host: "203.0.113.8" });
assert.equal(String(r.visible), "welcome\r\n");
});
test("createMoshConnectSniffer ignores unsafe MOSH IP values", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("MOSH IP --help\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
});
test("createMoshConnectSniffer strips the magic line from visible output", () => {
const sniffer = createMoshConnectSniffer();
const chunk = "shell prompt $ \r\nMOSH CONNECT 60003 ABCDEFGHIJKLMNOPQRSTUV==\r\nbye\r\n";
const { visible, parsed } = sniffer.feed(chunk);
assert.deepEqual(parsed, { port: 60003, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.ok(!String(visible).includes("MOSH CONNECT"), "visible output should not leak the marker");
});
test("createMoshConnectSniffer is idempotent after a parse", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.ok(r1.parsed);
// Second feed should not re-parse / re-strip — it just passes through.
const r2 = sniffer.feed("trailing bytes after handshake\r\n");
assert.equal(r2.parsed, null);
assert.equal(String(r2.visible), "trailing bytes after handshake\r\n");
});
test("createMoshConnectSniffer trims its ring buffer so old data doesn't accumulate", () => {
const sniffer = createMoshConnectSniffer();
// Feed >> RING_SIZE (4096) bytes of harmless output.
for (let i = 0; i < 10; i += 1) {
const r = sniffer.feed("x".repeat(1024));
assert.equal(r.parsed, null);
}
// Now feed a CONNECT line — ring trimming must not have lost the
// ability to match a fresh marker.
const r = sniffer.feed("MOSH CONNECT 60020 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.equal(r.parsed && r.parsed.port, 60020);
});
test("buildMoshClientEnv injects MOSH_KEY without mutating the input env", () => {
const base = { LANG: "C", PATH: "/x" };
const env = buildMoshClientEnv({ baseEnv: base, key: "deadbeef", lang: "C" });
assert.equal(env.MOSH_KEY, "deadbeef");
assert.equal(env.PATH, "/x");
assert.equal(base.MOSH_KEY, undefined, "input env should not be mutated");
});
test("buildMoshClientEnv defaults TERM when missing", () => {
const env = buildMoshClientEnv({ baseEnv: {}, key: "k", lang: "C" });
assert.equal(env.TERM, "xterm-256color");
});
test("resolveSshExecutable prefers PATH lookups", () => {
const resolved = resolveSshExecutable({
findExecutable: () => "/opt/ssh/bin/ssh",
fileExists: () => true,
platform: "linux",
});
assert.equal(resolved, "/opt/ssh/bin/ssh");
});
test("resolveSshExecutable falls back to in-box OpenSSH on win32", () => {
process.env.SystemRoot = "C:\\Windows";
const resolved = resolveSshExecutable({
findExecutable: () => "ssh", // fakes "not found, returns the bare name"
fileExists: (p) => p.endsWith("OpenSSH\\ssh.exe"),
platform: "win32",
});
assert.equal(resolved, "C:\\Windows\\System32\\OpenSSH\\ssh.exe");
});
test("resolveSshExecutable returns null when nothing is found", () => {
const resolved = resolveSshExecutable({
findExecutable: () => "ssh",
fileExists: () => false,
platform: "linux",
});
assert.equal(resolved, null);
});

View File

@@ -35,6 +35,10 @@ const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
// Match any private key file: id_* but not *.pub
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
function quoteShellArg(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
@@ -1990,10 +1994,56 @@ async function getSessionPwd(event, payload) {
resolve({ success: false, error: 'Timeout getting pwd' });
}, 5000);
// Find the interactive shell's cwd silently via a separate exec channel.
// Both the exec channel and the interactive shell share the same sshd
// parent ($PPID). We exclude our own PID ($$) to avoid reading our own cwd.
const cmd = `p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; eval echo "~"`;
// POSIX sh script that:
// 1. Finds the sibling interactive shell under sshd ($PPID).
// 2. Follows foreground child shells only, which covers bash->fish
// without mistaking background shell scripts for the active shell.
// 3. Reads /proc/<pid>/cwd via readlink.
// 4. Falls back to the user's home directory if anything fails.
//
// `exec` makes sh replace the user's login shell (fish/bash/...)
// so sh keeps the same PID and $PPID = sshd. Starting another shell
// without exec would make $PPID point at the intermediate shell instead.
const posixScript = `SELF=$$
find_child_shell() {
mode=$2
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
if (index($3, "+") > 0) { print $1; found=1; exit }
if (mode != "foreground" && pid == "") pid=$1
}
END { if (!found && mode != "foreground" && pid != "") print pid }
'
}
pid=$(find_child_shell "$PPID" any)
while [ -n "$pid" ]; do
child=$(find_child_shell "$pid" foreground)
[ -n "$child" ] || break
pid="$child"
done
if [ -n "$pid" ]; then
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
fi
emit_home() {
case "$1" in
/*) printf '%s\\n' "$1"; exit 0 ;;
esac
}
home=$(eval echo "~" 2>/dev/null)
emit_home "$home"
uid=$(id -u 2>/dev/null)
if [ -n "$uid" ]; then
home=$(getent passwd "$uid" 2>/dev/null | awk -F: 'NR == 1 { print $6; exit }')
emit_home "$home"
home=$(awk -F: -v uid="$uid" '$3 == uid { print $6; exit }' /etc/passwd 2>/dev/null)
emit_home "$home"
fi
home=$(id -P 2>/dev/null | awk -F: 'NR == 1 { print $9; exit }')
emit_home "$home"
emit_home "$HOME"
exit 1`;
const cmd = `exec sh -c ${quoteShellArg(posixScript)}`;
session.conn.exec(cmd, (err, stream) => {
if (err) {

View File

@@ -0,0 +1,134 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { addBundledMoshDllPath, resolveBareMoshClient } = require("./terminalBridge.cjs");
function makeTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resolve-"));
}
function writeExecutable(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
fs.chmodSync(filePath, 0o755);
}
test("resolveBareMoshClient ignores explicit local mosh-client paths", () => {
const tmp = makeTmp();
const p = path.join(tmp, "mosh-client");
writeExecutable(p);
assert.equal(resolveBareMoshClient({ moshClientPath: p }, { projectRoot: tmp, resourcesPath: path.join(tmp, "missing") }), null);
});
test("resolveBareMoshClient resolves only the bundled client", () => {
const tmp = makeTmp();
const bundled = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(bundled);
assert.equal(
resolveBareMoshClient({}, {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
}),
bundled,
);
});
test("resolveBareMoshClient rejects relative explicit paths", () => {
const tmp = makeTmp();
const got = resolveBareMoshClient({ moshClientPath: "./mosh-client" }, {
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
});
assert.equal(got, null);
});
test("resolveBareMoshClient ignores a non-executable explicit path", () => {
const tmp = makeTmp();
const p = path.join(tmp, "mosh-client");
fs.writeFileSync(p, "");
fs.chmodSync(p, 0o644);
const got = resolveBareMoshClient({ moshClientPath: p }, {
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
});
assert.equal(got, null);
});
test("resolveBareMoshClient ignores mosh-client on PATH", () => {
const tmp = makeTmp();
const p = path.join(tmp, "mosh-client");
writeExecutable(p);
assert.equal(resolveBareMoshClient({}, {
pathOverride: tmp,
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
}), null);
});
test("mosh fallback messages do not point users to the removed Mosh settings field", () => {
const source = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
assert.equal(source.includes("Settings → Terminal → Mosh"), false);
});
test("mosh runtime does not fall back to system mosh or mosh-client", () => {
const source = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
assert.equal(source.includes('resolvePosixExecutable("mosh-client"'), false);
assert.equal(source.includes('findExecutable("mosh-client"'), false);
assert.equal(source.includes('resolvePosixExecutable("mosh"'), false);
assert.equal(source.includes('findExecutable("mosh"'), false);
assert.equal(source.includes("brew install mosh"), false);
});
test("Windows dev mosh-client prepends the bundled DLL directory", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
const env = { Path: "C:\\Windows\\System32" };
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.Path.split(";")[0], dllDir);
});
test("Windows dev mosh-client updates the PATH key used by child process env", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
const env = {
Path: "C:\\Windows\\System32",
PATH: "C:\\Tools",
};
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.PATH.split(";")[0], dllDir);
assert.equal(Object.prototype.hasOwnProperty.call(env, "Path"), false);
});
test("removed Mosh client detection APIs are not exposed to the renderer", () => {
const bridgeSource = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
const preloadSource = fs.readFileSync(path.join(__dirname, "..", "preload.cjs"), "utf8");
const globalTypes = fs.readFileSync(path.join(__dirname, "..", "..", "global.d.ts"), "utf8");
for (const source of [bridgeSource, preloadSource, globalTypes]) {
assert.equal(source.includes("detectMoshClient"), false);
assert.equal(source.includes("pickMoshClient"), false);
assert.equal(source.includes("netcatty:mosh:detectClient"), false);
assert.equal(source.includes("netcatty:mosh:pickClient"), false);
}
});

View File

@@ -0,0 +1,101 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { bundledMoshClient } = require("./terminalBridge.cjs");
function makeTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-"));
}
function writeExecutable(filePath, contents = "#!/bin/sh\nexit 0\n") {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents);
fs.chmodSync(filePath, 0o755);
}
test("bundledMoshClient returns null when no binary is present", () => {
const projectRoot = makeTmp();
const result = bundledMoshClient({
platform: "linux",
arch: "x64",
projectRoot,
resourcesPath: path.join(projectRoot, "missing-resources"),
});
assert.equal(result, null);
});
test("bundledMoshClient prefers the packaged Resources path", () => {
const projectRoot = makeTmp();
const resourcesPath = makeTmp();
const packagedBin = path.join(resourcesPath, "mosh", "mosh-client");
writeExecutable(packagedBin);
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(devBin);
const result = bundledMoshClient({ platform: "linux", arch: "x64", projectRoot, resourcesPath });
assert.equal(result, packagedBin);
});
test("bundledMoshClient falls back to the project-root dev path", () => {
const projectRoot = makeTmp();
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(devBin);
const result = bundledMoshClient({
platform: "linux",
arch: "x64",
projectRoot,
resourcesPath: path.join(projectRoot, "missing"),
});
assert.equal(result, devBin);
});
test("bundledMoshClient looks under darwin-universal regardless of arch on macOS", () => {
const projectRoot = makeTmp();
const universalBin = path.join(projectRoot, "resources", "mosh", "darwin-universal", "mosh-client");
writeExecutable(universalBin);
for (const arch of ["arm64", "x64"]) {
const result = bundledMoshClient({
platform: "darwin",
arch,
projectRoot,
resourcesPath: path.join(projectRoot, "missing"),
});
assert.equal(result, universalBin, `arch=${arch}`);
}
});
test("bundledMoshClient uses .exe basename on win32 (when running on a POSIX host)", { skip: process.platform === "win32" }, () => {
const projectRoot = makeTmp();
const winBin = path.join(projectRoot, "resources", "mosh", "win32-x64", "mosh-client.exe");
writeExecutable(winBin);
const result = bundledMoshClient({
platform: "win32",
arch: "x64",
projectRoot,
resourcesPath: path.join(projectRoot, "missing"),
});
assert.equal(result, winBin);
});
test("bundledMoshClient ignores non-executable matches", () => {
const projectRoot = makeTmp();
const candidate = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
fs.mkdirSync(path.dirname(candidate), { recursive: true });
fs.writeFileSync(candidate, "");
fs.chmodSync(candidate, 0o644);
const result = bundledMoshClient({
platform: "linux",
arch: "x64",
projectRoot,
resourcesPath: path.join(projectRoot, "missing"),
});
assert.equal(result, null);
});

View File

@@ -19,6 +19,7 @@ const { detectShellKind } = require("./ai/ptyExec.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
// Shared references
let sessions = null;
@@ -229,12 +230,17 @@ function isWindowsAppExecutionAlias(filePath) {
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
}
function findExecutable(name) {
function findExecutable(name, opts = {}) {
if (process.platform !== "win32") return name;
const { execFileSync } = require("child_process");
try {
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
? opts.pathOverride
: process.env.PATH;
const env = { ...process.env, PATH: pathOverride || "" };
const whereExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "where.exe");
const result = execFileSync(fs.existsSync(whereExe) ? whereExe : "where.exe", [name], { encoding: "utf8", env });
const candidates = result
.split(/\r?\n/)
.map((line) => line.trim())
@@ -249,7 +255,6 @@ function findExecutable(name) {
console.warn(`Could not find ${name} via where.exe:`, err.message);
}
const path = require("node:path");
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
const commonPaths = [];
@@ -779,229 +784,388 @@ async function startTelnetSession(event, options) {
}
/**
* Start a Mosh session using system mosh-client
* Resolve Netcatty's bundled bare `mosh-client` binary.
*
* Returns the absolute path or null.
*/
async function startMoshSession(event, options) {
const sessionId = options.sessionId || randomUUID();
function resolveBareMoshClient(_options, opts = {}) {
return bundledMoshClient(opts);
}
function getEnvPathKey(env) {
const pathKeys = Object.keys(env).filter((key) => key.toLowerCase() === "path");
if (pathKeys.length === 0) return "PATH";
return pathKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0];
}
function getEnvPathDelimiter(opts = {}) {
return (opts.platform || process.platform) === "win32" ? ";" : path.delimiter;
}
function normalizeEnvPathPart(part, opts = {}) {
const pathApi = (opts.platform || process.platform) === "win32" ? path.win32 : path;
return pathApi.normalize(part).toLowerCase();
}
function prependEnvPath(env, dir, opts = {}) {
if (!dir) return env;
const pathKey = getEnvPathKey(env);
const duplicatePathKeys = Object.keys(env)
.filter((key) => key.toLowerCase() === "path" && key !== pathKey);
for (const key of duplicatePathKeys) {
delete env[key];
}
const current = env[pathKey] || "";
const delimiter = getEnvPathDelimiter(opts);
const parts = String(current).split(delimiter).filter(Boolean);
const normalizedDir = normalizeEnvPathPart(dir, opts);
if (!parts.some((part) => normalizeEnvPathPart(part, opts) === normalizedDir)) {
env[pathKey] = current ? `${dir}${delimiter}${current}` : dir;
}
return env;
}
function findBundledMoshDllDir(bareClient, opts = {}) {
const platform = opts.platform || process.platform;
if (platform !== "win32" || !bareClient) return null;
const clientDir = path.dirname(bareClient);
const arch = opts.arch || process.arch;
const preferred = path.join(clientDir, `mosh-client-win32-${arch}-dlls`);
if (fs.existsSync(preferred) && fs.statSync(preferred).isDirectory()) {
return preferred;
}
try {
const match = fs.readdirSync(clientDir)
.map((name) => path.join(clientDir, name))
.find((candidate) => {
const name = path.basename(candidate);
return /^mosh-client-win32-.+-dlls$/.test(name)
&& fs.existsSync(candidate)
&& fs.statSync(candidate).isDirectory();
});
return match || null;
} catch {
return null;
}
}
function addBundledMoshDllPath(env, bareClient, opts = {}) {
const dllDir = findBundledMoshDllDir(bareClient, opts);
return dllDir ? prependEnvPath(env, dllDir, opts) : env;
}
function createMoshSshPasswordResponder(sshPty, password) {
if (typeof password !== "string" || password.length === 0) {
return () => {};
}
let answered = false;
let tail = "";
return (chunk) => {
if (answered) return;
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
if (!text) return;
tail = (tail + text).slice(-512);
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
answered = true;
sshPty.write(`${password}\r`);
};
}
/**
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
* user's terminal PTY* so password / 2FA / known-hosts prompts render
* naturally, then swap to a bare `mosh-client` once `MOSH CONNECT` is
* detected. Replaces both the upstream Mosh Perl wrapper and the
* earlier non-PTY (BatchMode-style) implementation that couldn't show
* prompts.
*
* State machine:
* ssh-spawn ──onData──▶ sniffer.feed ──visible──▶ renderer
* └──parsed──▶ remember port/key
* ssh-pty exits ─────▶ if parsed: spawn mosh-client + swap
* else: surface error
*
* The session keeps a stable sessionId across the swap. session.proc
* is updated atomically before any user input arrives at the new
* mosh-client (writeToSession / resizeSession route through
* session.proc, so they automatically address the right process). The
* ZMODEM sentry is recreated for the new proc because its
* writeToRemote closure captures the previous handle.
*
* Caller has already validated that `bareClient` and `sshExe` exist.
*/
async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe }) {
const sessionId = options.sessionId || randomUUID();
const cols = options.cols || 80;
const rows = options.rows || 24;
// Resolve the mosh client to an absolute path before spawning. Bare names
// rely on the spawn-time PATH search, which on macOS GUI apps is reduced to
// `/usr/bin:/bin:/usr/sbin:/sbin` and silently fails for Homebrew installs
// (see issue #842). On Windows keep the existing behaviour.
//
// Resolution must consider the same PATH the spawned process will see —
// host-level `environmentVariables.PATH` is merged into the child env
// below, so the resolver checks that merged value first to avoid
// rejecting a binary the child would actually have found.
const optionsEnv = options.env || {};
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
? optionsEnv.PATH
: process.env.PATH;
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
let moshCmd;
let resolvedMoshDir = null;
// 1. Honor user-supplied moshClientPath (Settings → Terminal → Mosh).
// Strict failure: a missing/non-executable file produces a clear error
// instead of silently falling back, so users notice typos / stale paths.
const explicitClient = typeof options.moshClientPath === "string" ? options.moshClientPath.trim() : "";
if (explicitClient) {
const expanded = expandHomePath(explicitClient);
// Reject relative paths up front. validatePath in the renderer is shared
// with localShell and resolves bare names through PATH (so "mosh.exe"
// would look valid in the UI), but here moshClientPath is taken as a
// literal filesystem path and any non-absolute value would be resolved
// against the app's cwd and silently fail.
if (!path.isAbsolute(expanded)) {
throw new Error(
`Mosh client path must be absolute: "${explicitClient}". Use Settings → Terminal → Mosh to pick the binary, leave it empty to auto-detect, or enter an absolute path.`,
);
}
if (!isExecutableFile(expanded)) {
throw new Error(
`Configured Mosh client not usable: ${explicitClient}. Update Settings → Terminal → Mosh, leave it empty to auto-detect, or pick another binary.`,
);
}
moshCmd = path.resolve(expanded);
// Always remember the directory so we can extend PATH and locate
// mosh-client / ssh helpers regardless of platform — Windows
// installs outside %PATH% otherwise can't resolve siblings even
// though the wrapper itself runs.
resolvedMoshDir = path.dirname(moshCmd);
} else if (process.platform === "win32") {
moshCmd = findExecutable("mosh") || "mosh.exe";
} else {
const resolved = resolvePosixExecutable("mosh", { pathOverride: mergedPathForResolution });
if (!resolved) {
const installHint =
process.platform === "darwin"
? "macOS: brew install mosh"
: "Linux: sudo apt install mosh / sudo dnf install mosh / sudo pacman -S mosh";
throw new Error(
`Mosh client not found on PATH. Install it (${installHint}) or place the 'mosh' binary somewhere on PATH such as /opt/homebrew/bin or /usr/local/bin. You can also point Settings → Terminal → Mosh at an absolute path.`,
);
}
moshCmd = resolved;
resolvedMoshDir = path.dirname(resolved);
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
host: options.hostname,
port: options.port,
username: options.username,
lang,
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
});
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
const args = [];
const sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
env: sshEnv,
cwd: os.homedir(),
encoding: null,
});
if (options.port && options.port !== 22) {
args.push('--ssh=ssh -p ' + options.port);
}
if (options.moshServerPath) {
args.push('--server=' + options.moshServerPath);
}
const userHost = options.username
? `${options.username}@${options.hostname}`
: options.hostname;
args.push(userHost);
const resolveLangFromCharset = (charset) => {
if (!charset) return 'en_US.UTF-8';
const trimmed = String(charset).trim();
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) {
return 'en_US.UTF-8';
}
return trimmed;
const session = {
proc: sshPty,
pty: sshPty,
type: "mosh",
protocol: "mosh",
webContentsId: event.sender.id,
hostname: options.hostname || "",
username: options.username || "",
label: options.label || options.hostname || "Mosh Session",
shellKind: "posix",
shellExecutable: "remote-shell",
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
cols,
rows,
moshHandshakePhase: "ssh",
moshHandshakeResult: null,
};
sessions.set(sessionId, session);
const env = {
...process.env,
...optionsEnv,
TERM: 'xterm-256color',
LANG: resolveLangFromCharset(options.charset),
};
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(),
});
}
// The mosh wrapper is a Perl script that exec's `mosh-client` (and `ssh`)
// by name, so it needs them on PATH. Prepend the resolved mosh's directory
// to the env PATH (typical layout: mosh + mosh-client live side by side).
// Also point MOSH_CLIENT at the absolute mosh-client when present, so the
// wrapper picks it up even if PATH is overridden downstream.
if (resolvedMoshDir) {
const sep = path.delimiter; // ":" on POSIX, ";" on Win32
const existingPath = env.PATH || "";
const onPath = existingPath
.split(sep)
.some((p) => p && path.normalize(p) === path.normalize(resolvedMoshDir));
if (!onPath) {
env.PATH = existingPath ? `${resolvedMoshDir}${sep}${existingPath}` : resolvedMoshDir;
}
if (!env.MOSH_CLIENT) {
const clientCandidates =
process.platform === "win32"
? ["mosh-client.exe", "mosh-client.bat", "mosh-client.cmd", "mosh-client"]
: ["mosh-client"];
for (const name of clientCandidates) {
const candidate = path.join(resolvedMoshDir, name);
if (isExecutableFile(candidate)) {
env.MOSH_CLIENT = candidate;
break;
}
const { bufferData, flush } = createPtyBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
session.flushPendingData = flush;
const sniffer = moshHandshake.createMoshConnectSniffer();
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
// Forward bytes from the ssh PTY to the renderer, redacting the
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
// during handshake — it can't appear during ssh login output and
// would only complicate the swap.
sshPty.onData((chunk) => {
const { visible, parsed } = sniffer.feed(chunk);
if (visible && (visible.length || (typeof visible === "string" && visible))) {
const str = Buffer.isBuffer(visible) ? visible.toString("utf8") : visible;
if (str.length > 0) {
respondToPasswordPrompt(str);
bufferData(str);
sessionLogStreamManager.appendData(sessionId, str);
}
}
}
if (parsed && session.moshHandshakePhase === "ssh") {
session.moshHandshakePhase = "parsed";
session.moshHandshakeResult = parsed;
}
});
sshPty.onExit(({ exitCode, signal }) => {
if (sessions.get(sessionId) !== session || session.closed) {
return;
}
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
try {
swapToMoshClient(session, options, {
bareClient,
optionsEnv,
lang,
parsed: session.moshHandshakeResult,
bufferData,
flush,
sessionId,
});
} catch (err) {
flush();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", {
sessionId,
reason: "error",
error: `Failed to spawn mosh-client: ${err.message}`,
});
sessions.delete(sessionId);
}
return;
}
// Handshake failed before MOSH CONNECT — ssh exited without parse.
// The user has already seen the failure output (auth error, host
// key warning, etc). Just surface a session-exit with the code so
// the renderer can label the session "disconnected".
flush();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", {
sessionId,
exitCode,
signal,
reason: "error",
});
sessions.delete(sessionId);
});
return { sessionId };
}
/**
* Mid-session PTY swap: replaces session.proc (currently the ssh
* handshake PTY) with a freshly-spawned mosh-client PTY, re-wiring
* the data / exit listeners and (on POSIX) recreating the ZMODEM
* sentry whose writeToRemote closure captured the previous handle.
*/
function swapToMoshClient(session, options, ctx) {
const { bareClient, optionsEnv, lang, parsed, bufferData, flush, sessionId } = ctx;
const env = moshHandshake.buildMoshClientEnv({
baseEnv: { ...process.env, ...optionsEnv, TERM: "xterm-256color" },
key: parsed.key,
lang,
});
addBundledMoshDllPath(env, bareClient);
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
try {
const proc = pty.spawn(moshCmd, args, {
cols,
rows,
env,
cwd: os.homedir(),
encoding: null, // Return Buffer for ZMODEM binary support
const { command, args: clientArgs } = moshHandshake.buildMoshClientCommand({
moshClientPath: bareClient,
host: parsed.host || options.hostname,
port: parsed.port,
});
const mcPty = pty.spawn(command, clientArgs, {
cols: session.cols,
rows: session.rows,
env,
cwd: os.homedir(),
encoding: null,
});
// Atomic swap — writeToSession / resizeSession both read
// session.proc lazily, so any keystroke that arrives after this
// assignment goes to mosh-client, not the dead ssh PTY.
session.proc = mcPty;
session.pty = mcPty;
session.moshHandshakePhase = "mosh-client";
if (process.platform !== "win32") {
const decoder = new StringDecoder("utf8");
const sentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = decoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return mcPty.write(buf); } catch { return true; }
},
getWebContents() { return electronModule.webContents.fromId(session.webContentsId); },
protocolLabel: "Mosh",
});
const session = {
proc,
pty: proc,
type: 'mosh',
protocol: 'mosh',
webContentsId: event.sender.id,
hostname: options.hostname || '',
username: options.username || '',
label: options.label || options.hostname || 'Mosh Session',
shellKind: 'posix',
shellExecutable: 'remote-shell',
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
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(),
});
}
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
session.zmodemSentry = sentry;
mcPty.onData((data) => sentry.consume(data));
} else {
mcPty.onData((data) => {
const str = data.toString("utf8");
trackSessionIdlePrompt(session, str);
bufferData(str);
sessionLogStreamManager.appendData(sessionId, str);
});
session.flushPendingData = flushMosh;
if (process.platform !== "win32") {
const moshDecoder = new StringDecoder("utf8");
const moshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = moshDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferMoshData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Mosh",
});
session.zmodemSentry = moshZmodemSentry;
proc.onData((data) => {
moshZmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
proc.onExit((evt) => {
flushMosh();
sessionLogStreamManager.stopStream(sessionId);
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Mosh non-zero exit typically means connection/auth failure — show error UI
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
});
return { sessionId };
} catch (err) {
console.error("[Mosh] Failed to start mosh session:", err.message);
throw err;
}
mcPty.onExit(({ exitCode, signal }) => {
if (sessions.get(sessionId) !== session || session.closed) {
return;
}
flush();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", {
sessionId,
exitCode,
signal,
reason: exitCode !== 0 ? "error" : "exited",
});
sessions.delete(sessionId);
});
}
function resolveLangFromCharsetForMosh(charset) {
if (!charset) return "en_US.UTF-8";
const trimmed = String(charset).trim();
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) return "en_US.UTF-8";
return trimmed;
}
/**
* Start a Mosh session.
*
* Netcatty only uses its bundled `mosh-client` binary here. System
* `mosh` / `mosh-client` installs are intentionally ignored so dev,
* CI, and release builds exercise the same binary.
*/
async function startMoshSession(event, options, opts = {}) {
const optionsEnv = options.env || {};
// Program discovery must consider the same PATH the spawned PTY will
// receive, including host-level terminal environment overrides.
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
? optionsEnv.PATH
: process.env.PATH;
const bareClient = resolveBareMoshClient(options, opts.moshClientLookup || {});
if (!bareClient) {
throw new Error(
"Bundled mosh-client not found. Run `npm run fetch:mosh:dev` for local dev, " +
"or ensure release packaging downloads the mosh binary release before building.",
);
}
const sshExe = moshHandshake.resolveSshExecutable({
findExecutable: (name) => (
process.platform === "win32"
? findExecutable(name, { pathOverride: mergedPathForResolution })
: resolvePosixExecutable(name, { pathOverride: mergedPathForResolution })
),
fileExists: (p) => isExecutableFile(p) || fs.existsSync(p),
});
if (!sshExe) {
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
}
return startMoshSessionViaHandshake(event, options, { bareClient, sshExe });
}
/**
@@ -1183,6 +1347,8 @@ function writeToSession(event, payload) {
function resizeSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
if (Number.isFinite(payload.cols)) session.cols = payload.cols;
if (Number.isFinite(payload.rows)) session.rows = payload.rows;
try {
if (session.stream) {
@@ -1214,6 +1380,7 @@ function resizeSession(event, payload) {
function closeSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
session.closed = true;
try {
session.zmodemSentry?.cancel();
@@ -1273,8 +1440,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:mosh:detectClient", () => detectMoshClient());
ipcMain.handle("netcatty:mosh:pickClient", () => pickMoshClient());
ipcMain.handle("netcatty:serial:start", startSerialSession);
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
@@ -1369,69 +1534,45 @@ function validatePath(event, payload) {
}
/**
* Run the same auto-discovery startMoshSession uses, surfacing the result
* (and the search list when nothing was found) to the Settings UI.
* Locate the mosh-client binary bundled by electron-builder via
* `extraResources` (see electron-builder.config.cjs and
* .github/workflows/build-mosh-binaries.yml).
*
* Returns an absolute path when the binary is on disk, otherwise null.
* In dev / non-packaged runs the path is computed against the project
* root so the helper is testable without packaging the app.
*
* Note this returns the network-protocol `mosh-client`, not the `mosh`
* wrapper script. Netcatty drives the SSH bootstrap itself and then
* launches this bundled client directly.
*/
function detectMoshClient() {
if (process.platform === "win32") {
const resolved = findExecutable("mosh");
const found = !!resolved && resolved !== "mosh" && fs.existsSync(resolved);
return {
platform: "win32",
found,
path: found ? resolved : null,
searchedPaths: [],
};
}
const dirs = [];
const seen = new Set();
for (const dir of (process.env.PATH || "").split(":")) {
if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
for (const dir of POSIX_EXTRA_PATH_DIRS) {
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
const home = process.env.HOME;
if (home) {
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
const dir = path.join(home, sub);
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
}
const resolved = resolvePosixExecutable("mosh");
return {
platform: process.platform,
found: !!resolved,
path: resolved,
searchedPaths: dirs,
};
}
function bundledMoshClient(opts = {}) {
const isWin = (opts.platform || process.platform) === "win32";
const basename = isWin ? "mosh-client.exe" : "mosh-client";
/**
* Open a native file picker so the user can select a Mosh client binary.
* Returns { canceled, filePath } so the renderer can decide what to do.
*/
async function pickMoshClient() {
const { dialog, BrowserWindow } = electronModule || {};
if (!dialog) {
return { canceled: true, filePath: null };
// Packaged: <Resources>/mosh/mosh-client[.exe]
const resourcesPath = opts.resourcesPath || process.resourcesPath;
if (resourcesPath) {
const packaged = path.join(resourcesPath, "mosh", basename);
if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged;
}
const win = BrowserWindow?.getFocusedWindow?.() || undefined;
const isWin = process.platform === "win32";
const result = await dialog.showOpenDialog(win, {
title: "Select Mosh client",
properties: ["openFile", "showHiddenFiles"],
filters: isWin
? [
{ name: "Executables", extensions: ["exe", "bat", "cmd"] },
{ name: "All Files", extensions: ["*"] },
]
: [{ name: "All Files", extensions: ["*"] }],
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { canceled: true, filePath: null };
// Dev fallback: resources/mosh/<platform-arch>/mosh-client[.exe] under
// the project root. Useful for `npm run start` after running
// `npm run fetch:mosh` locally.
const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", "..");
const platform = opts.platform || process.platform;
const arch = opts.arch || process.arch;
const candidates = [];
if (platform === "darwin") {
candidates.push(path.join(projectRoot, "resources", "mosh", "darwin-universal", basename));
} else {
candidates.push(path.join(projectRoot, "resources", "mosh", `${platform}-${arch}`, basename));
}
return { canceled: false, filePath: result.filePaths[0] };
for (const c of candidates) {
if (fs.existsSync(c) && isExecutableFile(c)) return c;
}
return null;
}
/**
@@ -1483,8 +1624,9 @@ module.exports = {
startLocalSession,
startTelnetSession,
startMoshSession,
detectMoshClient,
pickMoshClient,
bundledMoshClient,
resolveBareMoshClient,
addBundledMoshDllPath,
startSerialSession,
listSerialPorts,
writeToSession,

View File

@@ -0,0 +1,280 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Module = require("node:module");
class FakePty {
constructor(command, args, opts) {
this.command = command;
this.args = args;
this.opts = opts;
this.pid = FakePty.nextPid += 1;
this.dataHandlers = [];
this.exitHandlers = [];
this.writes = [];
this.resizes = [];
this.killed = false;
}
onData(handler) {
this.dataHandlers.push(handler);
}
onExit(handler) {
this.exitHandlers.push(handler);
}
write(data) {
this.writes.push(data);
}
resize(cols, rows) {
this.resizes.push({ cols, rows });
}
kill() {
this.killed = true;
}
emitData(data) {
for (const handler of this.dataHandlers) handler(data);
}
emitExit(evt) {
for (const handler of this.exitHandlers) handler(evt);
}
}
FakePty.nextPid = 1000;
function writeExecutable(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
fs.chmodSync(filePath, 0o755);
}
function loadBridgeWithFakePty(spawns) {
const bridgePath = require.resolve("./terminalBridge.cjs");
delete require.cache[bridgePath];
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "node-pty") {
return {
spawn(command, args, opts) {
const pty = new FakePty(command, args, opts);
spawns.push(pty);
return pty;
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
try {
return require("./terminalBridge.cjs");
} finally {
Module._load = originalLoad;
}
}
function makeHarness(t) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
const sshPath = path.join(binDir, "ssh");
const moshClientPath = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(sshPath);
writeExecutable(moshClientPath);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`;
t.after(() => { process.env.PATH = oldPath; });
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
const sessions = new Map();
const sent = [];
bridge.init({
sessions,
electronModule: {
webContents: {
fromId() {
return { send: (channel, payload) => sent.push({ channel, payload }) };
},
},
},
});
return {
bridge,
sessions,
sent,
spawns,
options: {
sessionId: "mosh-test-session",
hostname: "example.com",
username: "alice",
cols: 80,
rows: 24,
},
event: { sender: { id: 42 } },
lookupOpts: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
};
}
test("startMoshSession handshake path returns the same shape as the legacy path", async (t) => {
const h = makeHarness(t);
const result = await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
assert.deepEqual(result, { sessionId: "mosh-test-session" });
});
test("startMoshSession uses bundled mosh-client even when PATH contains another client", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-path-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
const sshPath = path.join(binDir, "ssh");
const pathMoshClient = path.join(binDir, "mosh-client");
const bundledMoshClient = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(sshPath);
writeExecutable(pathMoshClient);
writeExecutable(bundledMoshClient);
const oldPath = process.env.PATH;
process.env.PATH = "";
t.after(() => { process.env.PATH = oldPath; });
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
const sessions = new Map();
const sent = [];
bridge.init({
sessions,
electronModule: {
webContents: {
fromId() {
return { send: (channel, payload) => sent.push({ channel, payload }) };
},
},
},
});
const result = await bridge.startMoshSession(
{ sender: { id: 42 } },
{
sessionId: "mosh-path-session",
hostname: "example.com",
username: "alice",
cols: 80,
rows: 24,
env: { PATH: binDir },
},
{
moshClientLookup: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
},
);
assert.deepEqual(result, { sessionId: "mosh-path-session" });
assert.equal(spawns[0].command, sshPath);
spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(spawns[1].command, bundledMoshClient);
});
test("startMoshSession handshake path sends the existing exit event on failure", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
assert.ok(exit);
assert.equal(exit.payload.sessionId, "mosh-test-session");
assert.equal(exit.payload.reason, "error");
});
test("startMoshSession writes the saved password when ssh prompts for one", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, password: "saved-secret" },
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("(alice@example.com) Password:");
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
});
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(h.spawns.length, 2);
h.spawns[1].emitExit({ exitCode: 0, signal: 0 });
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
assert.ok(exit);
assert.equal(exit.payload.sessionId, "mosh-test-session");
assert.equal(exit.payload.reason, "exited");
});
test("startMoshSession fails when bundled mosh-client is missing even if PATH has mosh-client", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-missing-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
writeExecutable(path.join(binDir, "ssh"));
writeExecutable(path.join(binDir, "mosh-client"));
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
bridge.init({
sessions: new Map(),
electronModule: {
webContents: {
fromId() {
return { send() {} };
},
},
},
});
await assert.rejects(
bridge.startMoshSession(
{ sender: { id: 42 } },
{
sessionId: "mosh-missing-bundled",
hostname: "example.com",
username: "alice",
env: { PATH: binDir },
},
{
moshClientLookup: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
},
),
/Bundled mosh-client not found/,
);
assert.equal(spawns.length, 0);
});

View File

@@ -1263,21 +1263,36 @@ async function createWindow(electronModule, options) {
* calling `webContents.focus()` covers (2) so the renderer marks the page as
* focused regardless of whether the OS granted foreground.
*/
function showAndFocusWindow(win) {
if (!win || win.isDestroyed()) return;
try {
win.show();
} catch {
// ignore
}
if (process.platform === "win32") {
function restoreWindowInputFocus(win, options = {}) {
if (!win || win.isDestroyed()) return false;
const shouldShow = options.show === true;
const platform = options.platform || process.platform;
if (shouldShow) {
try {
win.setAlwaysOnTop(true);
win.focus();
win.setAlwaysOnTop(false);
win.show();
} catch {
// ignore
}
}
if (platform === "win32") {
try {
win.setAlwaysOnTop(true);
} catch {
// ignore
}
try {
win.focus();
} catch {
// ignore
} finally {
try {
win.setAlwaysOnTop(false);
} catch {
// ignore
}
}
} else {
try {
win.focus();
@@ -1285,6 +1300,7 @@ function showAndFocusWindow(win) {
// ignore
}
}
try {
if (win.webContents && !win.webContents.isDestroyed()) {
win.webContents.focus();
@@ -1292,6 +1308,11 @@ function showAndFocusWindow(win) {
} catch {
// ignore
}
return true;
}
function showAndFocusWindow(win) {
restoreWindowInputFocus(win, { show: true });
}
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
@@ -1576,6 +1597,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return false;
});
ipcMain.handle("netcatty:window:focus", (event) => {
const win = getWindowForIpcEvent(event);
return restoreWindowInputFocus(win);
});
ipcMain.handle("netcatty:setTheme", (_event, theme) => {
currentTheme = theme;
nativeTheme.themeSource = theme;
@@ -1754,6 +1780,8 @@ module.exports = {
getMainWindow,
getSettingsWindow,
isWindowUsable,
registerWindowHandlers,
restoreWindowInputFocus,
waitForRendererReady,
setIsQuitting,
openFallbackBrowser,

View File

@@ -1,7 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { isWindowUsable } = require("./windowManager.cjs");
const { isWindowUsable, registerWindowHandlers, restoreWindowInputFocus } = require("./windowManager.cjs");
function createWindowStub({ destroyed = false, webContents } = {}) {
return {
@@ -65,3 +65,145 @@ test("isWindowUsable can require a visible window", () => {
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
});
test("restoreWindowInputFocus focuses the window and renderer on Windows without showing hidden windows", () => {
const calls = [];
const win = {
isDestroyed() {
return false;
},
show() {
calls.push("show");
},
focus() {
calls.push("focus");
},
setAlwaysOnTop(value) {
calls.push(`alwaysOnTop:${value}`);
},
webContents: {
isDestroyed() {
return false;
},
focus() {
calls.push("webContents.focus");
},
},
};
const restored = restoreWindowInputFocus(win, { platform: "win32" });
assert.equal(restored, true);
assert.deepEqual(calls, [
"alwaysOnTop:true",
"focus",
"alwaysOnTop:false",
"webContents.focus",
]);
});
test("restoreWindowInputFocus clears Windows always-on-top even if window focus throws", () => {
const calls = [];
const win = {
isDestroyed() {
return false;
},
focus() {
calls.push("focus");
throw new Error("focus failed");
},
setAlwaysOnTop(value) {
calls.push(`alwaysOnTop:${value}`);
},
webContents: {
isDestroyed() {
return false;
},
focus() {
calls.push("webContents.focus");
},
},
};
const restored = restoreWindowInputFocus(win, { platform: "win32" });
assert.equal(restored, true);
assert.deepEqual(calls, [
"alwaysOnTop:true",
"focus",
"alwaysOnTop:false",
"webContents.focus",
]);
});
test("restoreWindowInputFocus can show the window when requested", () => {
const calls = [];
const win = {
isDestroyed() {
return false;
},
show() {
calls.push("show");
},
focus() {
calls.push("focus");
},
webContents: {
isDestroyed() {
return false;
},
focus() {
calls.push("webContents.focus");
},
},
};
const restored = restoreWindowInputFocus(win, { platform: "darwin", show: true });
assert.equal(restored, true);
assert.deepEqual(calls, ["show", "focus", "webContents.focus"]);
});
test("window focus IPC handler focuses the sender owner window", async () => {
const handlers = new Map();
const ipcMain = {
handle(channel, handler) {
handlers.set(channel, handler);
},
on(channel, handler) {
handlers.set(channel, handler);
},
};
const calls = [];
const win = {
isDestroyed() {
return false;
},
focus() {
calls.push("focus");
},
webContents: {
id: 101,
isDestroyed() {
return false;
},
focus() {
calls.push("webContents.focus");
},
},
};
registerWindowHandlers(ipcMain, { themeSource: "light" });
const result = await handlers.get("netcatty:window:focus")({
sender: {
id: 202,
getOwnerBrowserWindow() {
return win;
},
},
});
assert.equal(result, true);
assert.deepEqual(calls, ["focus", "webContents.focus"]);
});

View File

@@ -548,12 +548,6 @@ const api = {
const result = await ipcRenderer.invoke("netcatty:mosh:start", options);
return result.sessionId;
},
detectMoshClient: async () => {
return ipcRenderer.invoke("netcatty:mosh:detectClient");
},
pickMoshClient: async () => {
return ipcRenderer.invoke("netcatty:mosh:pickClient");
},
startLocalSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
return result.sessionId;
@@ -822,6 +816,7 @@ const api = {
windowClose: () => ipcRenderer.invoke("netcatty:window:close"),
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
onWindowFullScreenChanged: (cb) => {
fullscreenChangeListeners.add(cb);
return () => fullscreenChangeListeners.delete(cb);

10
global.d.ts vendored
View File

@@ -177,6 +177,7 @@ declare global {
sessionId?: string;
hostname: string;
username?: string;
password?: string;
port?: number;
moshServerPath?: string;
moshClientPath?: string;
@@ -187,13 +188,6 @@ declare global {
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
detectMoshClient?(): Promise<{
platform: string;
found: boolean;
path: string | null;
searchedPaths: string[];
}>;
pickMoshClient?(): Promise<{ canceled: boolean; filePath: string | null }>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
@@ -471,6 +465,7 @@ declare global {
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
windowFocus?(): Promise<boolean>;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
// Settings window
@@ -943,6 +938,7 @@ declare global {
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpListModels?(acpCommand: string, acpArgs?: string[], cwd?: string, providerId?: string, chatSessionId?: string): Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;

View File

@@ -462,10 +462,20 @@ body {
margin-left: 0 !important;
}
[data-streamdown="code-block-actions"] {
[data-streamdown="code-block"] > div:has(> [data-streamdown="code-block-actions"]) {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 10 !important;
display: flex !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
pointer-events: none !important;
}
[data-streamdown="code-block-actions"] {
position: static !important;
border: none !important;
background: none !important;
backdrop-filter: none !important;
@@ -507,10 +517,21 @@ body {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 12px 10px !important;
padding: 6px 12px 10px !important;
width: max-content !important;
min-width: 100% !important;
font-size: 12px !important;
line-height: 1.5 !important;
white-space: pre !important;
}
/* Streamdown table overrides */
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] {
gap: 4px !important;
padding: 4px 8px 8px !important;
}
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] > div:first-child > button,
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] > div:first-child > div > button {
padding: 3px !important;
}

View File

@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatAcpErrorForDisplay, runAcpAgentTurn } from './acpAgentAdapter';
import type { AcpAgentCallbacks } from './acpAgentAdapter';
import type { ExternalAgentConfig } from './types';
function createCallbacks(errors: string[]): AcpAgentCallbacks {
return {
onTextDelta: () => {},
onThinkingDelta: () => {},
onThinkingDone: () => {},
onToolCall: () => {},
onToolResult: () => {},
onError: (error) => errors.push(error),
onDone: () => {},
};
}
const acpConfig: ExternalAgentConfig = {
id: 'agent',
name: 'Agent',
command: 'agent',
enabled: true,
acpCommand: 'agent-acp',
acpArgs: [],
};
test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
assert.equal(
formatAcpErrorForDisplay({
error: {
code: 'invalid_model',
message: 'Model is not available',
},
}),
'Model is not available',
);
});
test('formatAcpErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
assert.equal(
formatAcpErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
'{"status":502,"detail":"Proxy failed"}',
);
});
test('formatAcpErrorForDisplay handles circular errors', () => {
const error: Record<string, unknown> = { status: 500 };
error.self = error;
assert.equal(
formatAcpErrorForDisplay(error),
'{"status":500,"self":"[Circular]"}',
);
});
test('runAcpAgentTurn formats structured startup errors', async () => {
const errors: string[] = [];
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => ({
ok: false,
error: {
error: {
code: 'invalid_model',
message: 'Model is not available',
},
},
}),
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: () => () => {},
onAiAcpDone: () => () => {},
onAiAcpError: () => () => {},
};
await runAcpAgentTurn(
bridge,
'request-1',
'chat-1',
acpConfig,
'hello',
createCallbacks(errors),
);
assert.deepEqual(errors, ['Model is not available']);
});
test('runAcpAgentTurn formats structured async error events', async () => {
const errors: string[] = [];
let onError: ((error: unknown) => void) | null = null;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => {
queueMicrotask(() => {
onError?.({
data: {
error: {
message: 'Proxy failed',
},
},
});
});
return { ok: true };
},
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: () => () => {},
onAiAcpDone: () => () => {},
onAiAcpError: (_requestId: unknown, cb: unknown) => {
onError = cb as (error: unknown) => void;
return () => {};
},
};
await runAcpAgentTurn(
bridge,
'request-2',
'chat-1',
acpConfig,
'hello',
createCallbacks(errors),
);
assert.deepEqual(errors, ['Proxy failed']);
});
test('runAcpAgentTurn formats structured stream error events', async () => {
const errors: string[] = [];
let onEvent: ((event: unknown) => void) | null = null;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => {
queueMicrotask(() => {
onEvent?.({
type: 'error',
error: {
error: {
message: 'Stream failed',
},
},
});
});
return { ok: true };
},
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: (_requestId: unknown, cb: unknown) => {
onEvent = cb as (event: unknown) => void;
return () => {};
},
onAiAcpDone: () => () => {},
onAiAcpError: () => () => {},
};
await runAcpAgentTurn(
bridge,
'request-3',
'chat-1',
acpConfig,
'hello',
createCallbacks(errors),
);
assert.deepEqual(errors, ['Stream failed']);
});

View File

@@ -49,11 +49,11 @@ interface AcpBridge {
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<{ ok: boolean; error?: string }>;
): Promise<{ ok: boolean; error?: unknown }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiAcpDone(requestId: string, cb: () => void): () => void;
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
onAiAcpError(requestId: string, cb: (error: unknown) => void): () => void;
}
interface StreamEvent {
@@ -73,6 +73,61 @@ export interface FileAttachment {
filePath?: string;
}
function safeJsonStringify(value: unknown): string | null {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, nestedValue: unknown) => {
if (typeof nestedValue !== 'object' || nestedValue === null) {
return nestedValue;
}
if (seen.has(nestedValue)) {
return '[Circular]';
}
seen.add(nestedValue);
return nestedValue;
});
} catch {
return null;
}
}
function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): string {
if (error == null) return '';
if (typeof error === 'string') return error;
if (typeof error === 'number' || typeof error === 'boolean') return String(error);
if (error instanceof Error) return error.message || error.name || '';
if (typeof error !== 'object') return String(error);
if (seen.has(error)) return '[Circular error]';
seen.add(error);
const record = error as Record<string, unknown>;
const data = record.data as Record<string, unknown> | undefined;
const nestedError = record.error as Record<string, unknown> | undefined;
const candidates: unknown[] = [
data?.message,
data?.error,
record.errorText,
record.message,
record.error,
record.cause,
nestedError?.message,
record.data,
];
for (const candidate of candidates) {
const message = formatAcpErrorValue(candidate, seen).trim();
if (message && message !== '{}') {
return message;
}
}
return safeJsonStringify(error) || String(error);
}
export function formatAcpErrorForDisplay(error: unknown): string {
return formatAcpErrorValue(error).trim() || 'Unknown error';
}
export async function runAcpAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
@@ -98,15 +153,11 @@ export async function runAcpAgentTurn(
}
const cleanupFns: (() => void)[] = [];
// Set up event listeners before starting stream
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
handleStreamEvent(event, callbacks);
});
cleanupFns.push(unsubEvent);
let settled = false;
let resolveDone!: () => void;
let resolveDone: () => void = () => {};
const donePromise = new Promise<void>((resolve) => {
resolveDone = resolve;
});
const settle = (fn?: () => void) => {
if (settled) return false;
settled = true;
@@ -115,22 +166,28 @@ export async function runAcpAgentTurn(
return true;
};
const donePromise = new Promise<void>((resolve) => {
resolveDone = resolve;
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
settle(() => {
callbacks.onDone();
});
});
cleanupFns.push(unsubDone);
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
settle(() => {
callbacks.onError(error);
});
});
cleanupFns.push(unsubError);
// Set up event listeners before starting stream
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
const streamFailed = handleStreamEvent(event, callbacks);
if (streamFailed) {
settle();
}
});
cleanupFns.push(unsubEvent);
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
settle(() => {
callbacks.onDone();
});
});
cleanupFns.push(unsubDone);
const unsubError = acpBridge.onAiAcpError(requestId, (error: unknown) => {
settle(() => {
callbacks.onError(formatAcpErrorForDisplay(error));
});
});
cleanupFns.push(unsubError);
// Handle abort
if (signal) {
@@ -167,12 +224,16 @@ export async function runAcpAgentTurn(
).then((result) => {
if (result?.ok === false) {
settle(() => {
callbacks.onError(result.error || 'Failed to start ACP stream');
callbacks.onError(
result.error == null
? 'Failed to start ACP stream'
: formatAcpErrorForDisplay(result.error),
);
});
}
}).catch((err: Error) => {
}).catch((err: unknown) => {
settle(() => {
callbacks.onError(err.message);
callbacks.onError(formatAcpErrorForDisplay(err));
});
}).finally(() => {
if (settled) {
@@ -195,32 +256,32 @@ function cleanup(fns: (() => void)[]) {
* Handle a single stream event from the AI SDK fullStream.
* Events come from `streamText().fullStream` in the main process.
*/
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): boolean {
switch (event.type) {
case 'text-delta': {
const text = (event.textDelta as string) || (event.delta as string) || '';
if (text) callbacks.onTextDelta(text);
break;
return false;
}
case 'reasoning-start': {
// Reasoning block started — nothing to render yet
break;
return false;
}
case 'reasoning-delta': {
const text = (event.delta as string) || '';
if (text) callbacks.onThinkingDelta(text);
break;
return false;
}
case 'reasoning-end': {
callbacks.onThinkingDone();
break;
return false;
}
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
const input = (event.input as Record<string, unknown>) || {};
const toolCallId = (event.toolCallId as string) || undefined;
callbacks.onToolCall(toolName, input, toolCallId);
break;
return false;
}
case 'tool-result': {
const toolCallId = (event.toolCallId as string) || '';
@@ -230,22 +291,24 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
? output
: JSON.stringify(output);
callbacks.onToolResult(toolCallId, result, toolName);
break;
return false;
}
case 'status': {
const msg = (event.message as string) || '';
if (msg) callbacks.onStatus?.(msg);
break;
return false;
}
case 'session-id': {
const sessionId = (event.sessionId as string) || '';
if (sessionId) callbacks.onSessionId?.(sessionId);
break;
return false;
}
case 'error': {
callbacks.onError(String(event.error || 'Unknown error'));
break;
callbacks.onError(formatAcpErrorForDisplay(event.error));
return true;
}
// step-start, step-finish, etc. — ignore silently
default:
return false;
}
}

View File

@@ -0,0 +1,356 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyOpenAIChatContinuationToBody,
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
isProviderContinuationForSource,
mergeProviderContinuation,
normalizeProviderContinuationOptions,
rawOpenAIChatChunkHasToolCalls,
withProviderContinuationSource,
} from './providerContinuation';
test('extracts OpenAI-compatible reasoning deltas from raw provider chunks', () => {
const first = extractProviderContinuationFromRawChunk({
choices: [
{
delta: {
reasoning_content: 'check ',
},
},
],
});
const second = extractProviderContinuationFromRawChunk({
choices: [
{
delta: {
reasoning_content: 'tools',
},
},
],
});
const merged = mergeProviderContinuation(first, second);
assert.equal(merged?.openAIChatAssistantFields?.reasoning_content, 'check tools');
assert.deepEqual(merged?.reasoningParts, [{ text: 'check tools' }]);
});
test('patches OpenAI-compatible assistant tool-call messages with saved continuation fields', () => {
const body = JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'inspect the host' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'run_command', arguments: '{}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
],
});
const patched = JSON.parse(
applyOpenAIChatContinuationToBody(body, [
{ reasoning_content: 'need shell context' },
]),
);
assert.equal(patched.messages[2].reasoning_content, 'need shell context');
});
test('patches the final assistant message after a tool result with saved continuation fields', () => {
const body = JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [
{ role: 'user', content: 'inspect the host' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'run_command', arguments: '{}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
{ role: 'assistant', content: 'host is healthy' },
{ role: 'user', content: 'continue' },
],
});
const patched = JSON.parse(
applyOpenAIChatContinuationToBody(body, [
{ reasoning_content: 'need shell context' },
{ reasoning_content: 'summarize result' },
]),
);
assert.equal(patched.messages[1].reasoning_content, 'need shell context');
assert.equal(patched.messages[3].reasoning_content, 'summarize result');
});
test('rebuilds OpenAI-compatible continuation fields from saved thinking for legacy history', () => {
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
assert.deepEqual(
getOpenAIChatAssistantFieldsForHistoryMessage(
{
thinking: 'legacy visible reasoning',
providerId: 'custom',
model: 'deepseek-v4-flash',
},
source,
),
{ reasoning_content: 'legacy visible reasoning' },
);
});
test('does not rebuild continuation fields from thinking when provider or model differs', () => {
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
assert.equal(
getOpenAIChatAssistantFieldsForHistoryMessage(
{
thinking: 'other provider reasoning',
providerId: 'openai',
model: 'deepseek-v4-flash',
},
source,
),
undefined,
);
assert.equal(
getOpenAIChatAssistantFieldsForHistoryMessage(
{
thinking: 'other model reasoning',
providerId: 'custom',
model: 'another-model',
},
source,
),
undefined,
);
assert.equal(
getOpenAIChatAssistantFieldsForHistoryMessage(
{
thinking: 'missing provider metadata',
model: 'deepseek-v4-flash',
},
source,
),
undefined,
);
assert.equal(
getOpenAIChatAssistantFieldsForHistoryMessage(
{
thinking: 'missing model metadata',
providerId: 'custom',
},
source,
),
undefined,
);
});
test('detects OpenAI-compatible tool calls in raw chunks', () => {
assert.equal(rawOpenAIChatChunkHasToolCalls({
choices: [
{
delta: {
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'run_command', arguments: '{}' },
},
],
},
},
],
}), true);
assert.equal(rawOpenAIChatChunkHasToolCalls({
choices: [{ delta: { reasoning_content: 'think' } }],
}), false);
assert.equal(rawOpenAIChatChunkHasToolCalls('[DONE]'), false);
});
test('merges provider reasoning metadata into the reasoning part it belongs to', () => {
const merged = mergeProviderContinuation(
{ reasoningParts: [{ text: 'consider options' }] },
{ reasoningParts: [{ text: '', providerOptions: { anthropic: { signature: 'sig-1' } } }] },
);
assert.deepEqual(merged?.reasoningParts, [
{
text: 'consider options',
providerOptions: { anthropic: { signature: 'sig-1' } },
},
]);
});
test('normalizes provider metadata without unsafe object keys', () => {
const unsafeMetadata = JSON.parse('{"google":{"thoughtSignature":"sig-1","__proto__":{"polluted":true},"nested":{"constructor":{"bad":true},"value":"safe"}},"__proto__":{"ignored":true}}');
const normalized = normalizeProviderContinuationOptions(unsafeMetadata);
assert.deepEqual(normalized, {
google: {
thoughtSignature: 'sig-1',
nested: { value: 'safe' },
},
});
assert.equal(Object.prototype.hasOwnProperty.call(normalized?.google ?? {}, '__proto__'), false);
});
test('merges equivalent provider options without depending on key order', () => {
const merged = mergeProviderContinuation(
{ reasoningParts: [{ text: 'one ', providerOptions: { google: { b: 2, a: 1 } } }] },
{ reasoningParts: [{ text: 'two', providerOptions: { google: { a: 1, b: 2 } } }] },
);
assert.deepEqual(merged?.reasoningParts, [
{
text: 'one two',
providerOptions: { google: { b: 2, a: 1 } },
},
]);
});
test('cleans nested unsafe provider option keys when merging saved data', () => {
const unsafeOptions = JSON.parse('{"google":{"nested":{"prototype":{"bad":true},"value":"safe"}}}');
const merged = mergeProviderContinuation(
{ reasoningParts: [{ text: 'one ', providerOptions: unsafeOptions }] },
{ reasoningParts: [{ text: 'two' }] },
);
assert.deepEqual(merged?.reasoningParts, [
{
text: 'one ',
providerOptions: { google: { nested: { value: 'safe' } } },
},
{ text: 'two' },
]);
});
test('tracks continuation source so provider switches do not replay hidden context', () => {
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
const continuation = withProviderContinuationSource(
{ openAIChatAssistantFields: { reasoning_content: 'think' } },
source,
);
assert.equal(isProviderContinuationForSource(continuation, source), true);
assert.equal(
isProviderContinuationForSource(continuation, {
providerConfigId: 'openai',
providerType: 'openai',
modelId: 'gpt-5',
}),
false,
);
});
test('drops old hidden context instead of relabeling it when sources differ', () => {
const deepseek = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
const openai = { providerConfigId: 'openai', providerType: 'openai', modelId: 'gpt-5' };
const merged = mergeProviderContinuation(
{ source: deepseek, openAIChatAssistantFields: { reasoning_content: 'old' } },
{ source: openai, reasoningParts: [{ text: 'new' }] },
);
assert.deepEqual(merged, {
source: openai,
reasoningParts: [{ text: 'new' }],
});
});
test('merges tool-call provider options by tool call id', () => {
const merged = mergeProviderContinuation(
{ toolCallProviderOptionsById: { call_1: { google: { thoughtSignature: 'sig-1' } } } },
{ toolCallProviderOptionsById: { call_1: { google: { extra: true } } } },
);
assert.deepEqual(merged?.toolCallProviderOptionsById, {
call_1: {
google: {
thoughtSignature: 'sig-1',
extra: true,
},
},
});
});
test('skips plain assistant messages that are not part of a tool loop', () => {
const body = JSON.stringify({
stream: true,
messages: [
{ role: 'assistant', content: 'plain answer' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'run_command', arguments: '{}' },
},
],
},
],
});
const patched = JSON.parse(
applyOpenAIChatContinuationToBody(body, [
{ reasoning_content: 'tool reasoning' },
]),
);
assert.equal(patched.messages[0].reasoning_content, undefined);
assert.equal(patched.messages[1].reasoning_content, 'tool reasoning');
});
test('keeps assistant tool-call continuation fields aligned with message order', () => {
const toolCall = (id: string) => ({
id,
type: 'function',
function: { name: 'run_command', arguments: '{}' },
});
const body = JSON.stringify({
stream: true,
messages: [
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
{ role: 'assistant', content: '', tool_calls: [toolCall('call_2')] },
],
});
const patched = JSON.parse(
applyOpenAIChatContinuationToBody(body, [
undefined,
{ reasoning_content: 'second reasoning' },
]),
);
assert.equal(patched.messages[0].reasoning_content, undefined);
assert.equal(patched.messages[2].reasoning_content, 'second reasoning');
});
test('leaves invalid or unchanged OpenAI-compatible request bodies alone', () => {
assert.equal(applyOpenAIChatContinuationToBody('{', []), '{');
const body = JSON.stringify({ stream: true, messages: [{ role: 'user', content: 'hi' }] });
assert.equal(applyOpenAIChatContinuationToBody(body, [{ reasoning_content: 'unused' }]), body);
});

View File

@@ -0,0 +1,410 @@
export type ProviderContinuationJSONValue =
| string
| number
| boolean
| null
| ProviderContinuationJSONValue[]
| { [key: string]: ProviderContinuationJSONValue };
export type ProviderContinuationOptions = Record<string, Record<string, ProviderContinuationJSONValue>>;
export interface ProviderContinuationReasoningPart {
text: string;
providerOptions?: ProviderContinuationOptions;
}
export interface ProviderContinuationSource {
providerConfigId: string;
providerType: string;
modelId?: string;
}
export interface ProviderContinuation {
source?: ProviderContinuationSource;
reasoningParts?: ProviderContinuationReasoningPart[];
textProviderOptions?: ProviderContinuationOptions;
toolCallProviderOptionsById?: Record<string, ProviderContinuationOptions>;
openAIChatAssistantFields?: Record<string, unknown>;
}
export type OpenAIChatAssistantFields = Record<string, unknown>;
export interface ProviderContinuationHistoryMessage {
providerContinuation?: ProviderContinuation;
thinking?: string;
model?: string;
providerId?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isSafeObjectKey(key: string): boolean {
return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
}
function parseRawValue(rawValue: unknown): unknown {
if (typeof rawValue !== 'string') return rawValue;
try {
return JSON.parse(rawValue);
} catch {
return rawValue;
}
}
function toContinuationJSONValue(value: unknown): ProviderContinuationJSONValue | undefined {
if (value === null) return null;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
const values: ProviderContinuationJSONValue[] = [];
for (const item of value) {
const converted = toContinuationJSONValue(item);
if (converted !== undefined) values.push(converted);
}
return values;
}
if (isRecord(value)) {
const converted: { [key: string]: ProviderContinuationJSONValue } = {};
for (const [key, item] of Object.entries(value)) {
if (!isSafeObjectKey(key)) continue;
const convertedItem = toContinuationJSONValue(item);
if (convertedItem !== undefined) converted[key] = convertedItem;
}
return converted;
}
return undefined;
}
export function normalizeProviderContinuationOptions(value: unknown): ProviderContinuationOptions | undefined {
if (!isRecord(value)) return undefined;
const options: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(value)) {
if (!isSafeObjectKey(provider)) continue;
if (!isRecord(providerOptions)) continue;
const normalizedProviderOptions: Record<string, ProviderContinuationJSONValue> = {};
for (const [key, optionValue] of Object.entries(providerOptions)) {
if (!isSafeObjectKey(key)) continue;
const normalizedValue = toContinuationJSONValue(optionValue);
if (normalizedValue !== undefined) normalizedProviderOptions[key] = normalizedValue;
}
if (Object.keys(normalizedProviderOptions).length) {
options[provider] = normalizedProviderOptions;
}
}
return Object.keys(options).length ? options : undefined;
}
function cloneProviderOptions(options: ProviderContinuationOptions | undefined): ProviderContinuationOptions | undefined {
if (!options) return undefined;
const cloned: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
if (!isSafeObjectKey(provider)) continue;
const safeProviderOptions: Record<string, ProviderContinuationJSONValue> = {};
for (const [key, value] of Object.entries(providerOptions)) {
if (!isSafeObjectKey(key)) continue;
const normalizedValue = toContinuationJSONValue(value);
if (normalizedValue !== undefined) safeProviderOptions[key] = normalizedValue;
}
if (Object.keys(safeProviderOptions).length) cloned[provider] = safeProviderOptions;
}
return cloned;
}
function cloneReasoningPart(part: ProviderContinuationReasoningPart): ProviderContinuationReasoningPart {
return {
text: part.text,
...(part.providerOptions ? { providerOptions: cloneProviderOptions(part.providerOptions) } : {}),
};
}
function mergeProviderOptions(
current: ProviderContinuationOptions | undefined,
incoming: ProviderContinuationOptions | undefined,
): ProviderContinuationOptions | undefined {
if (!current && !incoming) return undefined;
const merged: ProviderContinuationOptions = cloneProviderOptions(current) ?? {};
for (const [provider, providerOptions] of Object.entries(incoming ?? {})) {
if (!isSafeObjectKey(provider)) continue;
const safeIncoming: Record<string, ProviderContinuationJSONValue> = {};
for (const [key, value] of Object.entries(providerOptions)) {
if (!isSafeObjectKey(key)) continue;
const normalizedValue = toContinuationJSONValue(value);
if (normalizedValue !== undefined) safeIncoming[key] = normalizedValue;
}
const existing = isRecord(merged[provider]) ? merged[provider] : undefined;
if (!existing && !Object.keys(safeIncoming).length) continue;
merged[provider] = {
...(existing ?? {}),
...safeIncoming,
};
}
return Object.keys(merged).length ? merged : undefined;
}
function mergeAssistantFields(
current: Record<string, unknown> | undefined,
incoming: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!current && !incoming) return undefined;
const merged: Record<string, unknown> = {};
for (const [key, value] of Object.entries(current ?? {})) {
if (!isSafeObjectKey(key)) continue;
merged[key] = value;
}
for (const [key, value] of Object.entries(incoming ?? {})) {
if (!isSafeObjectKey(key)) continue;
if (value === undefined) continue;
const safeValue = typeof value === 'string' ? value : toContinuationJSONValue(value);
if (safeValue === undefined) continue;
const previous = merged[key];
merged[key] = typeof previous === 'string' && typeof safeValue === 'string'
? previous + safeValue
: safeValue;
}
return Object.keys(merged).length ? merged : undefined;
}
function isSameProviderContinuationSource(
current: ProviderContinuationSource | undefined,
incoming: ProviderContinuationSource | undefined,
): boolean {
if (!current || !incoming) return false;
return current.providerConfigId === incoming.providerConfigId
&& current.providerType === incoming.providerType
&& current.modelId === incoming.modelId;
}
function stableJSONValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableJSONValue);
if (!isRecord(value)) return value;
const stable: Record<string, unknown> = {};
for (const key of Object.keys(value).filter(isSafeObjectKey).sort()) {
stable[key] = stableJSONValue(value[key]);
}
return stable;
}
function providerOptionsKey(options: ProviderContinuationOptions | undefined): string {
return JSON.stringify(stableJSONValue(options ?? {}));
}
function canMergeReasoningPart(
current: ProviderContinuationReasoningPart,
incoming: ProviderContinuationReasoningPart,
): boolean {
if (!incoming.text) return true;
return providerOptionsKey(current.providerOptions) === providerOptionsKey(incoming.providerOptions);
}
function appendReasoningParts(
current: ProviderContinuationReasoningPart[] | undefined,
incoming: ProviderContinuationReasoningPart[] | undefined,
): ProviderContinuationReasoningPart[] | undefined {
const merged = (current ?? []).map(cloneReasoningPart);
for (const part of incoming ?? []) {
if (!part.text && !part.providerOptions) continue;
const normalizedPart = cloneReasoningPart(part);
const last = merged.at(-1);
if (last && canMergeReasoningPart(last, normalizedPart)) {
last.text += normalizedPart.text;
const providerOptions = mergeProviderOptions(last.providerOptions, normalizedPart.providerOptions);
if (providerOptions) {
last.providerOptions = providerOptions;
} else {
delete last.providerOptions;
}
continue;
}
merged.push(normalizedPart);
}
return merged.length ? merged : undefined;
}
export function mergeProviderContinuation(
current?: ProviderContinuation | null,
incoming?: ProviderContinuation | null,
): ProviderContinuation | undefined {
const base = current?.source && incoming?.source && !isSameProviderContinuationSource(current.source, incoming.source)
? undefined
: current;
const reasoningParts = appendReasoningParts(base?.reasoningParts, incoming?.reasoningParts);
const textProviderOptions = mergeProviderOptions(base?.textProviderOptions, incoming?.textProviderOptions);
const toolCallProviderOptionsById = mergeToolCallProviderOptions(
base?.toolCallProviderOptionsById,
incoming?.toolCallProviderOptionsById,
);
const openAIChatAssistantFields = mergeAssistantFields(
base?.openAIChatAssistantFields,
incoming?.openAIChatAssistantFields,
);
const source = incoming?.source ?? base?.source;
if (!reasoningParts && !textProviderOptions && !toolCallProviderOptionsById && !openAIChatAssistantFields) {
return undefined;
}
return {
...(source ? { source } : {}),
...(reasoningParts ? { reasoningParts } : {}),
...(textProviderOptions ? { textProviderOptions } : {}),
...(toolCallProviderOptionsById ? { toolCallProviderOptionsById } : {}),
...(openAIChatAssistantFields ? { openAIChatAssistantFields } : {}),
};
}
function mergeToolCallProviderOptions(
current: Record<string, ProviderContinuationOptions> | undefined,
incoming: Record<string, ProviderContinuationOptions> | undefined,
): Record<string, ProviderContinuationOptions> | undefined {
if (!current && !incoming) return undefined;
const merged: Record<string, ProviderContinuationOptions> = {};
for (const [toolCallId, providerOptions] of Object.entries(current ?? {})) {
if (!isSafeObjectKey(toolCallId)) continue;
const cloned = cloneProviderOptions(providerOptions);
if (cloned) merged[toolCallId] = cloned;
}
for (const [toolCallId, providerOptions] of Object.entries(incoming ?? {})) {
if (!isSafeObjectKey(toolCallId)) continue;
const next = mergeProviderOptions(merged[toolCallId], providerOptions);
if (next) merged[toolCallId] = next;
}
return Object.keys(merged).length ? merged : undefined;
}
export function withProviderContinuationSource(
continuation: ProviderContinuation | undefined,
source: ProviderContinuationSource | undefined,
): ProviderContinuation | undefined {
if (!continuation) return undefined;
return source ? { ...continuation, source } : continuation;
}
export function isProviderContinuationForSource(
continuation: ProviderContinuation | undefined,
source: ProviderContinuationSource | undefined,
): boolean {
if (!continuation?.source || !source) return false;
return continuation.source.providerConfigId === source.providerConfigId
&& continuation.source.providerType === source.providerType
&& continuation.source.modelId === source.modelId;
}
export function getOpenAIChatAssistantFieldsForHistoryMessage(
message: ProviderContinuationHistoryMessage,
source: ProviderContinuationSource | undefined,
): OpenAIChatAssistantFields | undefined {
const activeContinuation = isProviderContinuationForSource(message.providerContinuation, source)
? message.providerContinuation
: undefined;
if (activeContinuation?.openAIChatAssistantFields) {
return activeContinuation.openAIChatAssistantFields;
}
if (!source) return undefined;
if (message.providerId !== source.providerType) return undefined;
if (source.modelId && message.model !== source.modelId) return undefined;
const thinking = typeof message.thinking === 'string' ? message.thinking.trim() : '';
return thinking ? { reasoning_content: thinking } : undefined;
}
export function extractProviderContinuationFromRawChunk(rawValue: unknown): ProviderContinuation | undefined {
const parsed = parseRawValue(rawValue);
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) return undefined;
let reasoningContent = '';
for (const choice of parsed.choices) {
if (!isRecord(choice)) continue;
const delta = isRecord(choice.delta) ? choice.delta : undefined;
const message = isRecord(choice.message) ? choice.message : undefined;
const rawReasoning = delta?.reasoning_content ?? message?.reasoning_content;
if (typeof rawReasoning === 'string' && rawReasoning) {
reasoningContent += rawReasoning;
}
}
if (!reasoningContent) return undefined;
return {
reasoningParts: [{ text: reasoningContent }],
openAIChatAssistantFields: { reasoning_content: reasoningContent },
};
}
export function rawOpenAIChatChunkHasToolCalls(rawValue: unknown): boolean {
const parsed = parseRawValue(rawValue);
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) return false;
for (const choice of parsed.choices) {
if (!isRecord(choice)) continue;
const delta = isRecord(choice.delta) ? choice.delta : undefined;
const message = isRecord(choice.message) ? choice.message : undefined;
if (delta && hasToolCalls(delta)) return true;
if (message && hasToolCalls(message)) return true;
}
return false;
}
function hasToolCalls(message: Record<string, unknown>): boolean {
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
}
function compactAssistantFields(fields: OpenAIChatAssistantFields | undefined): OpenAIChatAssistantFields | undefined {
const compacted: OpenAIChatAssistantFields = {};
for (const [key, value] of Object.entries(fields ?? {})) {
if (!isSafeObjectKey(key)) continue;
if (value === undefined || value === null || value === '') continue;
const safeValue = typeof value === 'string' ? value : toContinuationJSONValue(value);
if (safeValue === undefined || safeValue === null || safeValue === '') continue;
compacted[key] = safeValue;
}
return Object.keys(compacted).length ? compacted : undefined;
}
export function applyOpenAIChatContinuationToBody(
body: string,
assistantFieldsByContinuationMessage: Array<OpenAIChatAssistantFields | undefined>,
): string {
if (!assistantFieldsByContinuationMessage.length) return body;
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return body;
}
if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
let fieldIndex = 0;
let changed = false;
let previousMessageWasTool = false;
const messages = parsed.messages.map((message) => {
const isToolMessage = isRecord(message) && message.role === 'tool';
const needsContinuationFields = isRecord(message)
&& message.role === 'assistant'
&& (hasToolCalls(message) || previousMessageWasTool);
previousMessageWasTool = isToolMessage;
if (!needsContinuationFields) {
return message;
}
const fields = compactAssistantFields(assistantFieldsByContinuationMessage[fieldIndex]);
fieldIndex += 1;
if (!fields) return message;
changed = true;
return {
...message,
...mergeAssistantFields(message, fields),
};
});
if (!changed) return body;
return JSON.stringify({ ...parsed, messages });
}

View File

@@ -0,0 +1,387 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { stepCountIs, streamText, tool } from 'ai';
import { z } from 'zod';
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
import type { OpenAIChatAssistantFields } from './providerContinuation';
test('captures OpenAI-compatible reasoning_content before the tool follow-up request', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
const toolCall = {
id: 'call_1',
type: 'function',
function: { name: 'terminal_exec', arguments: '{}' },
};
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
if (sentBodies.length === 1) {
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'need shell ' } }] }));
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'context' } }] }));
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall] } }] }));
}
endHandlers.get(requestId)?.();
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const fetch = createBridgeFetchForSDK('deepseek-custom', {
getOpenAIChatAssistantFields: () => assistantFields,
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [{ role: 'user', content: 'inspect the host' }],
}),
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [
{ role: 'user', content: 'inspect the host' },
{ role: 'assistant', content: '', tool_calls: [toolCall] },
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
],
}),
});
const followUpBody = sentBodies[1];
const messages = followUpBody.messages as Array<Record<string, unknown>>;
assert.equal(messages[1].reasoning_content, 'need shell context');
});
test('does not duplicate reasoning_content when tool calls stream across chunks', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
if (sentBodies.length === 1) {
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'need shell context' } }] }));
emit(JSON.stringify({
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
id: 'call_1',
type: 'function',
function: { name: 'terminal_exec', arguments: '' },
}],
},
}],
}));
emit(JSON.stringify({
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
function: { arguments: '{}' },
}],
},
}],
}));
}
endHandlers.get(requestId)?.();
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const fetch = createBridgeFetchForSDK('deepseek-custom', {
getOpenAIChatAssistantFields: () => assistantFields,
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [{ role: 'user', content: 'inspect the host' }],
}),
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [
{ role: 'user', content: 'inspect the host' },
{
role: 'assistant',
content: '',
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'terminal_exec', arguments: '{}' },
}],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
],
}),
});
const followUpBody = sentBodies[1];
const messages = followUpBody.messages as Array<Record<string, unknown>>;
assert.equal(messages[1].reasoning_content, 'need shell context');
});
test('keeps captured reasoning_content aligned across consecutive tool calls', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
const toolCall = (id: string) => ({
id,
type: 'function',
function: { name: 'terminal_exec', arguments: '{}' },
});
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
if (sentBodies.length === 1) {
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'first tool reasoning' } }] }));
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall('call_1')] } }] }));
} else if (sentBodies.length === 2) {
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'second tool reasoning' } }] }));
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall('call_2')] } }] }));
}
endHandlers.get(requestId)?.();
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const fetch = createBridgeFetchForSDK('deepseek-custom', {
getOpenAIChatAssistantFields: () => assistantFields,
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [{ role: 'user', content: 'inspect the host' }],
}),
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [
{ role: 'user', content: 'inspect the host' },
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
],
}),
});
await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
body: JSON.stringify({
stream: true,
messages: [
{ role: 'user', content: 'inspect the host' },
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
{ role: 'assistant', content: '', tool_calls: [toolCall('call_2')] },
{ role: 'tool', tool_call_id: 'call_2', content: '{"ok":true}' },
],
}),
});
const secondRequestMessages = sentBodies[1].messages as Array<Record<string, unknown>>;
const thirdRequestMessages = sentBodies[2].messages as Array<Record<string, unknown>>;
assert.equal(secondRequestMessages[1].reasoning_content, 'first tool reasoning');
assert.equal(thirdRequestMessages[1].reasoning_content, 'first tool reasoning');
assert.equal(thirdRequestMessages[3].reasoning_content, 'second tool reasoning');
});
test('replays reasoning_content through the SDK tool loop', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
const toolCall = {
index: 0,
id: 'call_1',
type: 'function',
function: { name: 'terminal_exec', arguments: '{}' },
};
const emitChatChunk = (emit: (data: string) => void, delta: Record<string, unknown>, finishReason?: string) => {
emit(JSON.stringify({
id: 'chatcmpl-test',
object: 'chat.completion.chunk',
created: 1777600000,
model: 'deepseek-v4-flash',
choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
}));
};
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
const requestNumber = sentBodies.length;
setTimeout(() => {
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
if (requestNumber === 1) {
emitChatChunk(emit, { reasoning_content: 'need disk ' });
emitChatChunk(emit, { reasoning_content: 'context' });
emitChatChunk(emit, { tool_calls: [toolCall] });
emitChatChunk(emit, {}, 'tool_calls');
} else {
emitChatChunk(emit, { reasoning_content: 'read result' });
emitChatChunk(emit, { content: 'disk usage is 81%' });
emitChatChunk(emit, {}, 'stop');
}
endHandlers.get(requestId)?.();
}, 0);
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const model = createModelFromConfig(
{
id: 'deepseek-custom',
providerId: 'custom',
name: 'DeepSeek',
apiKey: 'test-key',
baseURL: 'https://api.deepseek.com',
defaultModel: 'deepseek-v4-flash',
enabled: true,
},
{ getOpenAIChatAssistantFields: () => assistantFields },
);
const result = streamText({
model,
messages: [{ role: 'user', content: 'inspect disk' }],
tools: {
terminal_exec: tool({
inputSchema: z.object({}),
execute: async () => ({ ok: true }),
}),
},
stopWhen: stepCountIs(2),
includeRawChunks: true,
});
for await (const _chunk of result.fullStream) {
// Drain the stream so the SDK completes the tool loop.
}
const followUpBody = sentBodies[1];
const messages = followUpBody.messages as Array<Record<string, unknown>>;
assert.equal(messages[1].reasoning_content, 'need disk context');
});

View File

@@ -2,6 +2,17 @@ import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import type { ProviderConfig } from '../types';
import {
applyOpenAIChatContinuationToBody,
extractProviderContinuationFromRawChunk,
mergeProviderContinuation,
rawOpenAIChatChunkHasToolCalls,
type OpenAIChatAssistantFields,
} from '../providerContinuation';
export interface ProviderRequestContext {
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
}
/**
* Bridge API subset used for SDK fetch adapter.
@@ -53,6 +64,57 @@ function isStreamingRequest(init?: RequestInit): boolean {
}
}
function mergeOpenAIChatAssistantFields(
current: OpenAIChatAssistantFields | undefined,
incoming: OpenAIChatAssistantFields | undefined,
): OpenAIChatAssistantFields | undefined {
return mergeProviderContinuation(
{ openAIChatAssistantFields: current },
{ openAIChatAssistantFields: incoming },
)?.openAIChatAssistantFields;
}
function createOpenAIChatStreamFieldCapture(
requestContext?: ProviderRequestContext,
): (data: string) => void {
const assistantFields = requestContext?.getOpenAIChatAssistantFields?.();
if (!assistantFields) return () => undefined;
let streamFieldIndex: number | undefined;
let pendingFields: OpenAIChatAssistantFields | undefined;
const ensureStreamFieldSlot = (): number => {
if (streamFieldIndex !== undefined) return streamFieldIndex;
streamFieldIndex = assistantFields.length;
assistantFields.push(undefined);
return streamFieldIndex;
};
const flushPendingFields = (fieldIndex: number) => {
if (!pendingFields) return;
assistantFields[fieldIndex] = mergeOpenAIChatAssistantFields(
assistantFields[fieldIndex],
pendingFields,
);
pendingFields = undefined;
};
return (data: string) => {
const continuation = extractProviderContinuationFromRawChunk(data);
const fields = continuation?.openAIChatAssistantFields;
if (fields) {
pendingFields = mergeOpenAIChatAssistantFields(pendingFields, fields);
if (streamFieldIndex !== undefined) {
flushPendingFields(streamFieldIndex);
}
}
if (rawOpenAIChatChunkHasToolCalls(data)) {
flushPendingFields(ensureStreamFieldSlot());
}
};
}
/**
* Extract headers as a plain Record<string, string> from various header formats.
*/
@@ -100,7 +162,10 @@ function toSafeStatusText(message: string, fallback: string): string {
return byteStringSafe.slice(0, 120) || fallback;
}
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
export function createBridgeFetchForSDK(
providerId?: string,
requestContext?: ProviderRequestContext,
): typeof globalThis.fetch {
return async (
input: string | URL | Request,
init?: RequestInit,
@@ -132,10 +197,17 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
const headers = extractHeaders(resolvedInit?.headers);
const body =
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
const requestBody = body != null
? applyOpenAIChatContinuationToBody(
body,
requestContext?.getOpenAIChatAssistantFields?.() ?? [],
)
: undefined;
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
// Set up IPC event listeners BEFORE starting the stream to avoid
// missing early events.
@@ -144,6 +216,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
let cleanedUp = false;
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
captureOpenAIChatFields(data);
// Re-wrap as SSE so the SDK can parse it
streamController?.enqueue(encoder.encode(`data: ${data}\n\n`));
});
@@ -186,7 +259,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
requestId,
url,
headers,
body || '',
requestBody || '',
providerId,
);
@@ -231,7 +304,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
}
// Non-streaming path
const result = await bridge.aiFetch(url, method, headers, body, providerId);
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
return new Response(result.data, {
status: result.status,
@@ -249,10 +322,13 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
* process replaces the placeholder with the real decrypted key before
* making the HTTP request.
*/
export function createModelFromConfig(config: ProviderConfig) {
export function createModelFromConfig(
config: ProviderConfig,
requestContext?: ProviderRequestContext,
) {
// Use placeholder API key — the main process will inject the real key
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
const customFetch = createBridgeFetchForSDK(config.id);
const customFetch = createBridgeFetchForSDK(config.id, requestContext);
const modelId = config.defaultModel || '';
switch (config.providerId) {

View File

@@ -1,4 +1,6 @@
// AI Provider types
import type { ProviderContinuation } from './providerContinuation';
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
export interface ProviderAdvancedParams {
@@ -69,6 +71,7 @@ export interface ChatMessage {
images?: ChatMessageAttachment[];
thinking?: string;
thinkingDurationMs?: number;
providerContinuation?: ProviderContinuation;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
timestamp: number;

View File

@@ -1425,7 +1425,6 @@ export class CloudSyncManager {
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
settings?: SyncPayload['settings'];
}): SyncPayload {
return {

View File

@@ -3,11 +3,11 @@ import { STORAGE_KEY_KNOWN_HOSTS } from './config/storageKeys';
import { localStorageAdapter } from './persistence/localStorageAdapter';
/**
* Get effective knownHosts for sync payload.
* Get effective knownHosts for local vault payloads.
*
* If the hook/state knownHosts is empty but localStorage has data,
* read from localStorage to avoid uploading an empty array that
* overwrites the cloud snapshot.
* read from localStorage so local backups do not miss entries while
* async store initialization is still settling.
*/
export function getEffectiveKnownHosts(
knownHostsFromState: KnownHost[] | undefined,

View File

@@ -65,7 +65,11 @@ export interface UploadCallbacks {
export interface UploadBridge {
writeLocalFile?: (path: string, data: ArrayBuffer) => Promise<void>;
mkdirLocal?: (path: string) => Promise<void>;
statLocal?: (path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
deleteLocalFile?: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
statSftp?: (sftpId: string, path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
deleteSftp?: (sftpId: string, path: string) => Promise<void>;
writeSftpBinary?: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress?: (
sftpId: string,
@@ -111,6 +115,17 @@ export interface UploadConfig {
callbacks?: UploadCallbacks;
/** Use compressed upload for folders (requires tar on both local and remote) */
useCompressedUpload?: boolean;
resolveConflict?: (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}) => Promise<'stop' | 'skip' | 'replace' | 'duplicate' | 'merge'>;
}
// ============================================================================
@@ -314,7 +329,7 @@ export async function uploadFromDataTransfer(
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
// Reset controller if provided
if (controller) {
@@ -324,6 +339,12 @@ export async function uploadFromDataTransfer(
// Create scanning placeholder
const scanningTaskId = crypto.randomUUID();
let scanningEnded = false;
const endScanning = () => {
if (scanningEnded) return;
scanningEnded = true;
callbacks?.onScanningEnd?.(scanningTaskId);
};
callbacks?.onScanningStart?.(scanningTaskId);
const scanT0 = performance.now();
@@ -331,22 +352,18 @@ export async function uploadFromDataTransfer(
try {
entries = await extractDropEntries(dataTransfer);
} catch (error) {
callbacks?.onScanningEnd?.(scanningTaskId);
endScanning();
throw error;
}
endScanning();
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
if (entries.length === 0) {
callbacks?.onScanningEnd?.(scanningTaskId);
return [];
}
if (!entries.some((entry) => !entry.isDirectory && entry.file)) {
callbacks?.onScanningEnd?.(scanningTaskId);
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
@@ -373,7 +390,7 @@ export async function uploadFromDataTransfer(
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
@@ -381,19 +398,19 @@ export async function uploadFromDataTransfer(
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
/**
@@ -404,7 +421,7 @@ export async function uploadFromFileList(
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
if (controller) {
controller.reset();
@@ -433,7 +450,7 @@ export async function uploadFromFileList(
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
@@ -460,7 +477,7 @@ export async function uploadFromFileList(
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
@@ -468,19 +485,19 @@ export async function uploadFromFileList(
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
/**
@@ -494,11 +511,59 @@ async function uploadEntries(
bridge: UploadBridge,
joinPath: (base: string, name: string) => string,
callbacks?: UploadCallbacks,
controller?: UploadController
controller?: UploadController,
resolveConflict?: UploadConfig["resolveConflict"]
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
const createdDirs = new Set<string>();
const statTarget = async (path: string) => {
try {
if (isLocal) return await bridge.statLocal?.(path);
if (sftpId) return await bridge.statSftp?.(sftpId, path);
} catch {
return null;
}
return null;
};
const deleteTarget = async (path: string) => {
if (isLocal) {
await bridge.deleteLocalFile?.(path);
} else if (sftpId) {
await bridge.deleteSftp?.(sftpId, path);
}
};
const splitNameForDuplicate = (name: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: name, ext: "" };
const lastDot = name.lastIndexOf(".");
if (lastDot <= 0) return { baseName: name, ext: "" };
return { baseName: name.slice(0, lastDot), ext: name.slice(lastDot) };
};
const getDuplicateName = async (name: string, isDirectory: boolean) => {
const { baseName, ext } = splitNameForDuplicate(name, isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const candidate = `${baseName}${suffix}${ext}`;
const candidatePath = joinPath(targetPath, candidate);
const existing = await statTarget(candidatePath);
if (!existing) return candidate;
}
return `${baseName} (copy ${Date.now()})${ext}`;
};
const renameRoot = (entry: DropEntry, oldName: string, newName: string): DropEntry => {
if (entry.relativePath === oldName) {
return { ...entry, relativePath: newName };
}
if (entry.relativePath.startsWith(`${oldName}/`)) {
return { ...entry, relativePath: `${newName}/${entry.relativePath.slice(oldName.length + 1)}` };
}
return entry;
};
const ensureDirectory = async (dirPath: string) => {
if (createdDirs.has(dirPath)) return;
@@ -518,7 +583,77 @@ async function uploadEntries(
// Group entries by root folder
const rootFolders = detectRootFolders(entries);
const sortedEntries = sortEntries(entries);
let resolvedEntries = entries;
if (resolveConflict) {
const resolved: DropEntry[] = [];
let stop = false;
const groups = Array.from(rootFolders.entries());
for (const [key, groupEntries] of groups) {
if (stop || controller?.isCancelled()) break;
const isStandaloneFile = key.startsWith("__file__");
const rootName = isStandaloneFile ? key.slice("__file__".length) : key;
const isDirectory = !isStandaloneFile;
const rootTargetPath = joinPath(targetPath, rootName);
const existing = await statTarget(rootTargetPath);
if (!existing) {
resolved.push(...groupEntries);
continue;
}
const newSize = groupEntries.reduce((sum, entry) => sum + (entry.file?.size ?? 0), 0);
const action = await resolveConflict({
fileName: rootName,
targetPath: rootTargetPath,
isDirectory,
existingType: existing.type,
existingSize: existing.size,
newSize,
existingModified: existing.lastModified,
newModified: Date.now(),
applyToAllCount: groups.filter(([groupKey]) => groupKey.startsWith("__file__") !== isDirectory).length,
});
if (action === "stop") {
stop = true;
await controller?.cancel();
resolved.length = 0;
results.push({ fileName: rootName, success: false, cancelled: true });
break;
}
if (action === "skip") {
results.push({ fileName: rootName, success: false, cancelled: true });
continue;
}
if (action === "replace") {
await deleteTarget(rootTargetPath);
resolved.push(...groupEntries);
continue;
}
if (action === "duplicate") {
const duplicateName = await getDuplicateName(rootName, isDirectory);
resolved.push(...groupEntries.map((entry) => renameRoot(entry, rootName, duplicateName)));
continue;
}
resolved.push(...groupEntries);
}
resolvedEntries = resolved;
}
if (resolvedEntries.length === 0) {
return results;
}
const resolvedRootFolders = detectRootFolders(resolvedEntries);
const sortedEntries = sortEntries(resolvedEntries);
// Pre-create all needed directories in batch before file transfers
const uploadT0 = performance.now();
@@ -572,7 +707,7 @@ async function uploadEntries(
// Create bundled tasks for each root folder
const bundleTaskIds = new Map<string, string>(); // rootName -> bundleTaskId
for (const [rootName, rootEntries] of rootFolders) {
for (const [rootName, rootEntries] of resolvedRootFolders) {
const isStandaloneFile = rootName.startsWith("__file__");
if (isStandaloneFile) continue;
@@ -947,7 +1082,7 @@ export async function uploadEntriesDirect(
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
if (controller) {
controller.reset();
@@ -959,7 +1094,7 @@ export async function uploadEntriesDirect(
}
// Support compressed folder uploads (same logic as uploadFromDataTransfer)
if (useCompressedUpload && !isLocal && sftpId) {
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
@@ -983,24 +1118,24 @@ export async function uploadEntriesDirect(
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, e]) => e);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
}
/**
* Upload folders using compression

View File

@@ -12,25 +12,28 @@
"netcatty-tool-cli": "./electron/cli/netcatty-tool-cli.cjs"
},
"scripts": {
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
"dev": "npm run fetch:mosh:dev && npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
"prebuild": "node scripts/copy-monaco.cjs",
"fetch:mosh": "node scripts/fetch-mosh-binaries.cjs",
"fetch:mosh:dev": "node scripts/fetch-mosh-binaries.cjs --host --resolve-release",
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
"pack:win": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --x64 --publish=never && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --arm64 --publish=never",
"pack:win-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --x64 --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "electron-builder install-app-deps && patch-package",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

109
resources/mosh/README.md Normal file
View File

@@ -0,0 +1,109 @@
# Bundled `mosh-client`
This directory holds the network-protocol-only `mosh-client` binary
bundled with the Netcatty installer. Netcatty drives the `ssh` +
`mosh-server` bootstrap itself and then launches this bundled client
directly (see `electron/bridges/moshHandshake.cjs` and
`electron/bridges/terminalBridge.cjs`).
## How binaries land here
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
produce one binary per target from upstream `mobile-shell/mosh`
source:
| target | provenance |
|-------------------|-----------------------------------------------------------------|
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
`fetch-windows.sh` is preserved as an emergency fallback that pulls
the FluentTerminal-pinned binary; it's no longer wired into the
default workflow.
2. When manually dispatched with `release_tag`, that workflow publishes
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
repository. The release gets a tag like `mosh-bin-1.4.0-1`, with
`SHA256SUMS` attached.
3. Release packaging runs `scripts/resolve-mosh-bin-release.cjs` before
`npm run fetch:mosh`. It uses an explicit workflow input first, then
the `MOSH_BIN_RELEASE` repository variable, then the latest
non-draft `mosh-bin-*` GitHub Release from the dedicated binary
repository. The fetch step pulls the binaries into
`resources/mosh/<platform-arch>/`. For local packaging, set
`MOSH_BIN_RELEASE` yourself before running the same fetch command.
Override `MOSH_BIN_OWNER` / `MOSH_BIN_REPO` only when testing a
different binary repository. `electron-builder.config.cjs` then
copies the matching binary into `Resources/mosh/mosh-client[.exe]`.
Local dev uses the same binary path: `npm run dev` runs
`npm run fetch:mosh:dev` first, which downloads the host platform's
bundled `mosh-client` into this gitignored directory. Netcatty does
not fall back to a system-installed `mosh` or `mosh-client`; if the
bundled binary is missing, Mosh startup fails loudly instead of using
whatever happens to be installed on the developer machine.
Official Windows package builds currently ship x64 only for bundled
Mosh coverage. Windows arm64 packaging should be re-enabled there
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
The directory is otherwise empty (binaries are gitignored).
## Licenses
- Mosh itself is licensed under **GPL-3.0**
(https://github.com/mobile-shell/mosh).
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
is permitted.
- The Windows binary is built in CI from upstream
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
redistributable Cygwin runtime libraries — see
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
the per-DLL license listing.
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
ncurses MIT) are compatible with GPL-3.0.
## Reproducible build
To reproduce the binaries locally:
```sh
docker run --rm -v $PWD:/workspace -w /workspace \
-e MOSH_REF=mosh-1.4.0 -e ARCH=x64 -e OUT_DIR=/workspace/out \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
```
For macOS the build needs an Xcode toolchain; see
`scripts/build-mosh/build-macos.sh`.
## Phase 2/3 — done in this PR
- `electron/bridges/moshHandshake.cjs` reimplements the upstream Mosh
Perl wrapper in Node: parser + sniffer + command builders as pure
functions.
- `terminalBridge.startMoshSession` runs the SSH bootstrap in a
node-pty so password / 2FA / known-hosts prompts render naturally
in the user's terminal, then swaps `session.proc` from the ssh PTY
to a freshly-spawned `mosh-client` PTY when `MOSH CONNECT` is
detected. Keystrokes that arrive after the swap go to mosh-client
because `writeToSession` reads `session.proc` lazily.
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
`ssh` client for the remote bootstrap. System-installed `mosh` /
`mosh-client` binaries are intentionally ignored.
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
alongside `cygwin1.dll` + transitive deps so it runs on a stock
Windows machine without a Cygwin install.
## Roadmap
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
leg using the same `build-windows.sh` script.
- Make `MOSH_REF` track upstream release tags automatically.

114
scripts/build-mosh/build-linux.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Build a portable mosh-client binary inside manylinux2014.
#
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
# OUT_DIR — directory to write mosh-client-linux-<arch> + sha256
#
# Output:
# $OUT_DIR/mosh-client-linux-<arch>
# $OUT_DIR/mosh-client-linux-<arch>.sha256
#
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
# scratch prefix, then build mosh against those and link libstdc++/libgcc
# statically. The resulting binary still depends on standard Linux system
# libraries such as glibc/libz/libutil from the manylinux2014 baseline
# (compatible with virtually every distro released since 2014, including
# Debian 9+, Ubuntu 18.04+, CentOS 7+).
set -euo pipefail
: "${MOSH_REF:?missing MOSH_REF}"
: "${ARCH:?missing ARCH}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_mosh_ref() {
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$MOSH_REF" == *..* ]] \
|| [[ "$MOSH_REF" == *@\{* ]] \
|| [[ "$MOSH_REF" == */ ]] \
|| [[ "$MOSH_REF" == *.lock ]]; then
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
exit 1
fi
}
validate_mosh_ref
OPENSSL_VER=3.0.13
PROTOBUF_VER=21.12
NCURSES_VER=6.4
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
PREFIX="$WORK/prefix"
mkdir -p "$PREFIX/lib" "$PREFIX/include" "$OUT_DIR"
yum install -y -q autoconf automake libtool perl perl-IPC-Cmd make gcc gcc-c++ pkgconfig zlib-devel
cd "$WORK"
# OpenSSL static
curl -fsSL "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" | tar xz
( cd "openssl-$OPENSSL_VER"
./config no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl"
make -j"$(nproc)"
make install_sw )
# protobuf static (3.x stays compatible with mosh's generated proto code)
curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" | tar xz
( cd "protobuf-3.$PROTOBUF_VER"
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic
make -j"$(nproc)"
make install )
# ncurses static
curl -fsSL "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" | tar xz
( cd "ncurses-$NCURSES_VER"
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" \
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec
make -j"$(nproc)"
make install )
# Mosh. Fetch the requested ref explicitly so branch names, tags, and commit
# SHAs all work from workflow_dispatch.
git init mosh
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
git -C mosh fetch --depth 1 origin "$MOSH_REF"
git -C mosh checkout --detach FETCH_HEAD
( cd mosh
export PATH="$PREFIX/bin:$PATH"
./autogen.sh
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$PREFIX/lib64/pkgconfig" \
./configure --enable-completion=no --disable-server \
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
LDFLAGS="-L$PREFIX/lib -L$PREFIX/lib64 -static-libstdc++ -static-libgcc" \
LIBS="-ldl -lpthread"
make -j"$(nproc)" )
OUT_BIN="$OUT_DIR/mosh-client-linux-$ARCH"
cp mosh/src/frontend/mosh-client "$OUT_BIN"
strip "$OUT_BIN"
echo "--- file ---"
file "$OUT_BIN"
echo "--- ldd ---"
ldd "$OUT_BIN" || true
echo "--- size ---"
ls -lh "$OUT_BIN"
# Sanity check: must not link any non-system shared libraries. Allow only
# the glibc runtime family and the ELF loader.
ldd "$OUT_BIN" > "$WORK/ldd.txt" || true
awk '
/=>/ { print $1; next }
/^[[:space:]]*\/.*ld-linux/ { print $1; next }
' "$WORK/ldd.txt" > "$WORK/deps.txt"
if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]+|/lib.*/ld-linux.*\.so\.[0-9]+|ld-linux.*\.so\.[0-9]+)$' "$WORK/deps.txt"; then
echo "ERROR: mosh-client links a non-system shared library; static linking failed." >&2
exit 1
fi
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH" > "mosh-client-linux-$ARCH.sha256" )
cat "$OUT_DIR/mosh-client-linux-$ARCH.sha256"

Some files were not shown because too many files have changed in this diff Show More