Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646e7ce001 | ||
|
|
21da34187e | ||
|
|
d2fa4f1cd9 | ||
|
|
72a6fc14f9 | ||
|
|
97c2cb1f86 | ||
|
|
73fd091b80 | ||
|
|
5bb4052f3d | ||
|
|
36e7e3cb7f | ||
|
|
25b73187f5 | ||
|
|
75a9600089 | ||
|
|
c9216b32ab | ||
|
|
70e374ef11 | ||
|
|
24840c539c | ||
|
|
461be76821 | ||
|
|
28a7184cc4 | ||
|
|
13f1453276 | ||
|
|
ff25c36ede | ||
|
|
092aa45fd9 | ||
|
|
64acf80024 | ||
|
|
099beb8438 | ||
|
|
8bee13c3f9 | ||
|
|
65cd8aba79 | ||
|
|
37856e5608 | ||
|
|
5b1deaa08a | ||
|
|
a41bced1d7 | ||
|
|
8c207a1dff | ||
|
|
99e1974a69 | ||
|
|
132bf288ac | ||
|
|
0a9f9848b7 | ||
|
|
11da55abf7 | ||
|
|
e751c0f23e | ||
|
|
f4b5beec01 | ||
|
|
9e2b8093fb | ||
|
|
7b2f66000c | ||
|
|
6e7593dee2 | ||
|
|
2098b2b09d | ||
|
|
8181fe71cf | ||
|
|
d06009684e | ||
|
|
55236ce34a | ||
|
|
b89f06b7f0 | ||
|
|
a01d1f770f | ||
|
|
f1fdb61195 | ||
|
|
9d0f6a9cea | ||
|
|
e0403412e7 | ||
|
|
bb67aa77f5 | ||
|
|
e948a7a869 | ||
|
|
3fc56df111 | ||
|
|
008890a688 | ||
|
|
178f56455e | ||
|
|
8376e35022 | ||
|
|
0b8206aecb | ||
|
|
203505bc25 | ||
|
|
c5ac85ae5b | ||
|
|
c6552ddc75 | ||
|
|
7972b19bdd | ||
|
|
33918a2433 | ||
|
|
9e7f6d98fd | ||
|
|
ceda20510f | ||
|
|
82f3250b5b | ||
|
|
c37e087332 | ||
|
|
f282c58edc | ||
|
|
45e208f1d8 | ||
|
|
132c597d1e | ||
|
|
85f486e6cd | ||
|
|
2c96773679 | ||
|
|
a8f9fd7a56 | ||
|
|
aae4ad4da8 | ||
|
|
014d7b4d39 | ||
|
|
5912339813 | ||
|
|
20bfa0e3bd | ||
|
|
41ebe0fa64 | ||
|
|
66cf610cc0 | ||
|
|
62ec391523 | ||
|
|
7927da2085 | ||
|
|
d45dea4bff | ||
|
|
816e274dfc | ||
|
|
1a20a6a4a8 | ||
|
|
910049b0ea | ||
|
|
173a83aafa | ||
|
|
b7093f88b1 | ||
|
|
2e66bcf254 | ||
|
|
95208294b0 | ||
|
|
a4bf2234cd | ||
|
|
e527e7233f | ||
|
|
afe959835d | ||
|
|
3b2b05064b | ||
|
|
1e94fe983f | ||
|
|
274ac4e0e1 | ||
|
|
1ad4443e3b | ||
|
|
031bf0ee45 | ||
|
|
0efe80b06d | ||
|
|
3fb7c6dd21 | ||
|
|
c7e4ac82ca | ||
|
|
d5e29598d3 | ||
|
|
fca7782634 | ||
|
|
42b23a9faa | ||
|
|
06011d01d6 | ||
|
|
4bf4e65df8 | ||
|
|
45e62ed43e |
222
.github/workflows/build-et-binaries.yml
vendored
Normal file
222
.github/workflows/build-et-binaries.yml
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
name: build-et-binaries
|
||||
|
||||
# Trigger philosophy (mirrors build-mosh-binaries.yml):
|
||||
# - Pushes that touch the et 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-et-bin` by default).
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding the et
|
||||
# binaries on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
et_ref:
|
||||
description: "EternalTerminal git ref (tag/branch/commit) — see https://github.com/MisterTea/EternalTerminal"
|
||||
type: string
|
||||
default: "et-v6.2.10"
|
||||
release_tag:
|
||||
description: "Optional release tag to attach binaries to (e.g. et-bin-6.2.10-1). Empty = artifacts only."
|
||||
type: string
|
||||
default: ""
|
||||
release_repo:
|
||||
description: "Repository that stores et binary releases."
|
||||
type: string
|
||||
default: "binaricat/Netcatty-et-bin"
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- ".github/workflows/build-et-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-et/**"
|
||||
- "scripts/fetch-et-binaries.cjs"
|
||||
- "scripts/et-extra-resources.cjs"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/build-et-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-et/**"
|
||||
- "scripts/fetch-et-binaries.cjs"
|
||||
- "scripts/et-extra-resources.cjs"
|
||||
|
||||
concurrency:
|
||||
group: build-et-binaries-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
ET_REF: ${{ inputs.et_ref || 'et-v6.2.10' }}
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
|
||||
# ------------------------------------------------------------------
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (linux-x64)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ET_REF="${ET_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=x64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-et/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-linux-x64
|
||||
path: out/
|
||||
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (linux-arm64)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ET_REF="${ET_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=arm64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_aarch64 \
|
||||
bash scripts/build-et/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-linux-arm64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# macOS universal2 (arm64 + x86_64 lipo). Min deployment target macOS 11.
|
||||
# ------------------------------------------------------------------
|
||||
build-macos-universal:
|
||||
name: build-macos-universal
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (darwin-universal)
|
||||
env:
|
||||
ET_REF: ${{ env.ET_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}/out
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
run: bash scripts/build-et/build-macos.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-darwin-universal
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 — static MSVC build (no DLL bundle).
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install ninja
|
||||
run: choco install -y ninja
|
||||
- name: Set up MSVC developer command prompt
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
- name: Build et (win32-x64)
|
||||
env:
|
||||
ET_REF: ${{ env.ET_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}\out
|
||||
shell: pwsh
|
||||
run: pwsh -File scripts/build-et/build-windows.ps1
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-win32-x64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built until a tested client exists.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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" =~ ^et-bin-[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Invalid et binary release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Create / update release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ET_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::ET_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'Pre-built EternalTerminal `et` client binaries consumed by `scripts/fetch-et-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Built from `MisterTea/EternalTerminal` upstream ref `%s`.\n\n' "${ET_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 Apache-2.0; see `resources/et/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
|
||||
110
.github/workflows/build.yml
vendored
110
.github/workflows/build.yml
vendored
@@ -29,6 +29,10 @@ on:
|
||||
description: "Release tag containing bundled mosh-client binaries"
|
||||
type: string
|
||||
default: ""
|
||||
et_bin_release:
|
||||
description: "Release tag containing bundled et (EternalTerminal) binaries"
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
@@ -54,6 +58,8 @@ permissions:
|
||||
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 != '') }}
|
||||
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
|
||||
BUNDLE_ET: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.et_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:
|
||||
@@ -191,9 +197,38 @@ jobs:
|
||||
fi
|
||||
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
resolve-et:
|
||||
name: resolve bundled et-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.et_bin_release != '')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
et_bin_release: ${{ steps.resolve.outputs.et_bin_release }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve bundled et-client release
|
||||
id: resolve
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/resolve-et-bin-release.cjs
|
||||
release="$(grep '^ET_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
|
||||
if [[ -z "$release" ]]; then
|
||||
echo "::error::ET_BIN_RELEASE was not resolved."
|
||||
exit 1
|
||||
fi
|
||||
echo "et_bin_release=${release}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -214,6 +249,7 @@ jobs:
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_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 }}
|
||||
@@ -230,6 +266,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -242,21 +289,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# 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}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
@@ -267,6 +299,16 @@ jobs:
|
||||
npm run fetch:mosh -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm run fetch:et -- --platform=darwin --arch=universal
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm run fetch:et -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -318,7 +360,7 @@ jobs:
|
||||
# See #264.
|
||||
build-linux-x64:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -326,6 +368,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -344,6 +387,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -379,6 +433,10 @@ jobs:
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=x64
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
run: npm run fetch:et -- --platform=linux --arch=x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
@@ -408,7 +466,7 @@ jobs:
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -418,6 +476,7 @@ jobs:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
|
||||
npm_config_arch: arm64
|
||||
npm_config_target_arch: arm64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -436,6 +495,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -474,6 +544,10 @@ jobs:
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
run: npm run fetch:et -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -73,3 +73,12 @@ build_with_vs2022.bat
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
/resources/mosh/*/terminfo/
|
||||
|
||||
# Bundled EternalTerminal `et` client binaries fetched at pack time by
|
||||
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
|
||||
# actual binaries (and any DLL bundle for dynamically-linked Windows builds)
|
||||
# are pulled from the dedicated et binary repository, never committed.
|
||||
/resources/et/*/et
|
||||
/resources/et/*/et.exe
|
||||
/resources/et/*/et-*-dlls/
|
||||
/resources/et/*/*.dll
|
||||
|
||||
61
App.tsx
61
App.tsx
@@ -36,6 +36,7 @@ import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLo
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -509,7 +510,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
|
||||
// Install blocked because an editor has unsaved changes (#1215). The main
|
||||
// process broadcasts this; show an actionable toast telling the user to save
|
||||
// and click "Restart Now" again.
|
||||
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
|
||||
});
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -700,7 +706,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
// close flow.
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => boolean | Promise<boolean>>(() => false);
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => { return createLocalTerminalWithCurrentShellImpl(() => ({ classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings })); }, [createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
@@ -722,6 +728,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
@@ -733,6 +745,43 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId,
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
sessionIds: sessions.map((session) => session.id),
|
||||
workspaceIds: workspaces.map((workspace) => workspace.id),
|
||||
logViewIds: logViews.map((logView) => logView.id),
|
||||
});
|
||||
|
||||
if (intent.kind === 'closeTab') {
|
||||
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === 'closeLogView') {
|
||||
closeLogView(intent.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, [activeTabId, closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
|
||||
void handleWindowCommandCloseRequest();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleWindowCommandCloseRequest]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
executeHotkeyAction(action, e);
|
||||
@@ -936,13 +985,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
|
||||
|
||||
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
|
||||
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
|
||||
216
ET_INTEGRATION_CHECKLIST.md
Normal file
216
ET_INTEGRATION_CHECKLIST.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# EternalTerminal (ET) 集成清单 — 按 Mosh 方式重做
|
||||
|
||||
> 目标:在上游最新架构(分支 `feat/et-history-reapply`,基于 `031bf0ee`)上,
|
||||
> **完全照搬 Mosh 的方式**重新集成 EternalTerminal:
|
||||
> 1. **打包客户端** —— 像 `mosh-client` 那样,把 `et` 客户端二进制构建 + 下载 +
|
||||
> 捆绑进安装包,运行时只用捆绑的二进制(不依赖系统安装的 et)。
|
||||
> 2. **接入协议** —— 把旧分支 `feat/eternal-terminal`(tip `67e81616`)里的 ET
|
||||
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
|
||||
>
|
||||
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
|
||||
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
|
||||
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
|
||||
|
||||
## 关键设计差异(ET vs Mosh)
|
||||
|
||||
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
|
||||
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
|
||||
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
|
||||
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
|
||||
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
|
||||
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
|
||||
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
|
||||
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
|
||||
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
|
||||
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
|
||||
产物是单个 `et`(Windows `et.exe`)。
|
||||
|
||||
## 命名约定(镜像 Mosh)
|
||||
|
||||
| Mosh | ET |
|
||||
|------|----|
|
||||
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
|
||||
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
|
||||
| `scripts/build-mosh/` | `scripts/build-et/` |
|
||||
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
|
||||
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
|
||||
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
|
||||
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
|
||||
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 打包基础设施(构建/下载/捆绑)
|
||||
|
||||
- [x] **1.1** `resources/et/README.md` —— 镜像 `resources/mosh/README.md`:说明
|
||||
二进制来源、`Netcatty-et-bin` 发布仓库、`et-bin-*` tag、许可证(ET 为
|
||||
Apache-2.0,与 GPL-3.0 兼容)、可复现构建命令。
|
||||
- [x] **1.2** `.gitignore` —— 追加 ET 段(镜像 mosh 段):
|
||||
`/resources/et/*/et`、`/resources/et/*/et.exe`、`/resources/et/*/*.dll`、
|
||||
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
|
||||
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
|
||||
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
|
||||
校验非系统动态库(同 mosh 的 ldd 白名单)。
|
||||
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
|
||||
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
|
||||
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
|
||||
`x64-windows-static`,产物 `et-win32-x64.tar.gz`(含 `et.exe`;若动态链接 CRT
|
||||
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
|
||||
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
|
||||
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
|
||||
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
|
||||
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
|
||||
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
|
||||
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
|
||||
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
|
||||
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
|
||||
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
|
||||
(镜像对应 mosh 测试,改名/改路径)。
|
||||
- [x] **1.10** `package.json` scripts:新增
|
||||
`"fetch:et": "node scripts/fetch-et-binaries.cjs"`、
|
||||
`"fetch:et:dev": "node scripts/fetch-et-binaries.cjs --host --resolve-release"`;
|
||||
把 `dev` 脚本改成先 `fetch:mosh:dev && fetch:et:dev`;`test` glob 已覆盖
|
||||
`scripts/*.test.cjs`(确认即可)。
|
||||
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
|
||||
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
|
||||
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
|
||||
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
|
||||
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
|
||||
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
|
||||
|
||||
## Phase 2 — 运行时定位捆绑客户端
|
||||
|
||||
- [x] **2.1** `electron/bridges/terminalBridge.cjs` 新增 `bundledEtClient(opts)`
|
||||
—— 镜像 `bundledMoshClient`:打包路径 `<Resources>/et/et[.exe]`;dev 回退
|
||||
`<projectRoot>/resources/et/<plat-arch>/et[.exe]`;导出到 module.exports。
|
||||
|
||||
## Phase 3 — ET 协议后端(搬运旧实现到新架构)
|
||||
|
||||
- [x] **3.1** 新建 `electron/bridges/terminalBridge/etSession.cjs` —— 用上游
|
||||
`moshSession.cjs` 的 `createXxxSessionApi(ctx)` + `with(ctx)` 工厂模式,封装:
|
||||
`ET_ASKPASS_SCRIPT`、`writeSecureFile`、`prepareEtSshEnvironment`、
|
||||
`createEtAskpassArtifacts`、`cleanupStaleEtTempDirs`、
|
||||
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
|
||||
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
|
||||
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
|
||||
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
|
||||
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
|
||||
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
|
||||
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
|
||||
`cleanupSessionExternalAuthArtifacts`、`resolveBareEtClient`。
|
||||
- [x] **3.3** `init()` 调 `cleanupStaleEtTempDirs()`;`registerHandlers` 加
|
||||
`ipcMain.handle("netcatty:et:start", startEtSession)`;`closeSession` 与
|
||||
`cleanupAllSessions` 调 `cleanupSessionExternalAuthArtifacts(session)`;
|
||||
`module.exports` 导出 `startEtSession`、`execOnEtSession`、`bundledEtClient`。
|
||||
- [x] **3.4** 测试:`terminalBridge.bundledEt.test.cjs`(路径解析)+
|
||||
`terminalBridge/etSession.test.cjs`(prepareEtSshEnvironment 的端口/密钥/
|
||||
askpass/跳板/legacy 算法分支)。可参考旧分支是否已有 ET 测试并搬运。
|
||||
|
||||
## Phase 4 — domain / 类型 / preload 接口面
|
||||
|
||||
- [x] **4.1** `domain/models.ts`:`HostProtocol` 加 `'et'`;`ProtocolConfig.etPort?`;
|
||||
`Host`/`GroupConfig` 加 `etEnabled?`/`etPort?`/`etTerminalPath?`;
|
||||
`TerminalSession.etEnabled?`;`ConnectionLog.protocol` 加 `'et'`。
|
||||
(照搬 `git show 794eecdf -- domain/models.ts`)
|
||||
- [x] **4.2** `domain/groupConfig.ts`:加 `etEnabled` 默认项(照搬旧 diff)。
|
||||
- [x] **4.3** `global.d.ts`:`NetcattyBridge` 加 `startEtSession?(options): Promise<...>`
|
||||
及相关 options 类型(照搬 `git show 794eecdf -- global.d.ts`,并补齐后续 ET 提交
|
||||
新增的 etPort/terminalPath/jumpHosts/legacyAlgorithms 字段)。
|
||||
- [x] **4.4** `electron/preload/api.cjs`:加 `startEtSession`(镜像第 26 行的
|
||||
`startMoshSession`)→ `ipcRenderer.invoke("netcatty:et:start", options)`。
|
||||
**注意**:上游已把 preload 重构成 `createPreloadApi`,落点在 `preload/api.cjs`,
|
||||
不是旧的 `preload.cjs` 内联对象。
|
||||
|
||||
## Phase 5 — 渲染层 + UI + i18n
|
||||
|
||||
- [x] **5.1** `application/state/useTerminalBackend.ts`:加 `etAvailable`(查
|
||||
`bridge?.startEtSession`)+ `startEtSession`,并在返回对象/依赖数组里登记
|
||||
(镜像 mosh 的第 10/42/198/205 行处)。
|
||||
- [x] **5.2** `application/state/useSessionState.ts`:路由 ET 会话(照搬旧 diff,+6 行)。
|
||||
- [x] **5.3** `components/terminal/runtime/createTerminalSessionStarters.ts`:加
|
||||
`startEt(term)`(镜像 `startMosh`,组装 options:etPort/terminalPath/
|
||||
jumpHosts/legacyAlgorithms/凭证/identityFilePaths)。
|
||||
**注意**:上游把它从旧的 `infrastructure/runtime/` 移到了
|
||||
`components/terminal/runtime/` —— 落点以上游为准。
|
||||
- [x] **5.4** UI 组件(照搬 `git show b1a306f8 6c0d5bf3 55caa268` 的相应文件,
|
||||
映射到上游同名组件):
|
||||
- [ ] `components/ProtocolSelectDialog.tsx` —— 新增 ET 选项
|
||||
- [ ] `components/QuickConnectWizard.tsx`
|
||||
- [ ] `components/HostDetailsPanel.tsx` —— ET 设置(启用、ET 端口、etterminal 路径)
|
||||
- [ ] `components/GroupDetailsPanel.tsx`
|
||||
- [ ] `components/VaultView.tsx`
|
||||
- [ ] `components/Terminal.tsx` / `components/TerminalLayer.tsx`
|
||||
- [ ] `components/terminal/TerminalConnectionDialog.tsx` / `TerminalToolbar.tsx`
|
||||
- [ ] `App.tsx`
|
||||
- [x] **5.5** i18n:`application/i18n/locales/en.ts` 与 `zh-CN.ts` 加 ET 文案
|
||||
(照搬旧 diff,键名对齐上游现有 mosh 文案结构)。
|
||||
|
||||
## Phase 6 — 校验
|
||||
|
||||
- [x] **6.1** `npm run lint`(确保新 .cjs 在 scripts/ 下不受 ESLint 限制,
|
||||
或按需加 eslint-disable,与 mosh 脚本一致)。
|
||||
- [x] **6.2** `npm test`(新增的 fetch/resolve/extra-resources/etSession 测试全绿)。
|
||||
- [x] **6.3** `npm run build`(渲染层 TS 编译通过,无类型错误)。
|
||||
- [ ] **6.4** 手动冒烟(需先有发布的二进制):
|
||||
`ET_BIN_RELEASE=et-bin-... npm run fetch:et` → `npm run start` →
|
||||
新建 ET 会话连一台装了 etserver 的主机,验证连接/输入/退出/凭证注入。
|
||||
|
||||
---
|
||||
|
||||
## 进度记录
|
||||
|
||||
- 状态:**Phase 1–5 已完成并通过校验**(仅余 1 个可选项 + CI 产二进制)
|
||||
- 验证结果:
|
||||
- `npx eslint <所有改动文件>` → 干净(0 错 0 警)
|
||||
- `npx tsc --noEmit` → 我的改动 **0 个新增类型错误**
|
||||
(`TerminalConnectionDialog` 里 `case 'mosh'` 的 TS2678 是既有问题,行号因我插入 ET 早返回从 60→64,非新增)
|
||||
- `node --test`(ET 相关)→ etSession/bundledEt/3 个脚本测试 **全绿**
|
||||
- `npm test` → 1383 通过 / 16 失败,**16 个全是既有的 Windows 环境失败**
|
||||
(mosh 打包测试的 GNU-tar `C:` 问题、`isExecutableFile` 无 x 位、ACP execPath、SKILL.md 权限、Comware DH 等;均在我未改动的文件里)
|
||||
- `npm run build`(Vite)→ **构建成功**(8.55s),渲染层打包通过
|
||||
|
||||
### 已完成
|
||||
- **Phase 1**:`scripts/et-extra-resources.cjs` / `resolve-et-bin-release.cjs` /
|
||||
`fetch-et-binaries.cjs`(+3 测试,27 通过)、`scripts/build-et/{build-linux.sh,
|
||||
build-macos.sh,build-windows.ps1}`、`.github/workflows/build-et-binaries.yml`、
|
||||
`resources/et/README.md`、`.gitignore`、`package.json`、`electron-builder.config.cjs`。
|
||||
- **Phase 2**:`terminalBridge.cjs` 新增并导出 `bundledEtClient`。
|
||||
- **Phase 3**:`terminalBridge/etSession.cjs`(startEtSession + prepareEtSshEnvironment +
|
||||
SSH_ASKPASS 机制 + execOnEtSession + 清理),接线进 terminalBridge.cjs(ctx/IPC
|
||||
`netcatty:et:start`/init 清理/close/quit 清理/导出),+2 测试(13 通过)。
|
||||
**et 指向捆绑二进制**(resolveBareEtClient→bundledEtClient),找不到则报错。
|
||||
- **Phase 4**:domain `connection.ts`/`history.ts`/`terminal.ts`、`groupConfig.ts`、
|
||||
`types/global/netcatty-bridge-session.d.ts`(startEtSession + NetcattyJumpHost[])、
|
||||
`electron/preload/api.cjs`、`domain/vaultImport.ts`(排除 'et' 导入协议)。
|
||||
- **Phase 5**:
|
||||
- 启动派发:`useTerminalEffects.ts`、`Terminal.tsx`(×3) → `startEt`
|
||||
- 运行时 starter:`createTerminalSessionStarters.ts` 新增 `startEt`(含单跳板/凭证/
|
||||
legacy 算法/askpass 路径),`.types.ts` 加 `etAvailable`/`startEtSession`
|
||||
- 后端 hook:`useTerminalBackend.ts`(etAvailable + startEtSession)
|
||||
- 会话透传 etEnabled:`sessionFactories.ts`、`useSessionState.ts`(×6)、
|
||||
`TerminalLayer.tsx`(×3)、`TerminalLayerSupport.tsx`、`AppHandlers.ts`(协议解析/日志/选择)
|
||||
- UI:`HostDetailsAdvancedSections.tsx`(ET 开关+端口+etterminal 路径,与 Mosh 互斥)、
|
||||
`HostDetailsPanel.tsx`、`ProtocolSelectDialog.tsx`(ET 选项)、
|
||||
`TerminalConnectionDialog.tsx`(ET 标签)、`TerminalToolbar.tsx`(编码菜单门控)、
|
||||
`GroupSshSettingsSection.tsx` + `GroupDetailsPanel.tsx`(组级 ET)、`VaultView.tsx`
|
||||
- i18n:en/zh-CN 的 `hostDetails.section.et`、`hostDetails.et.*`、
|
||||
`terminal.connection.protocol.et`、`terminal.et.*`
|
||||
|
||||
### 剩余(可选 / 非阻塞)
|
||||
- [ ] **QuickConnectWizard.tsx**:把 ET 加为“快速连接”协议按钮(type/端口/建主机映射 +
|
||||
UI 按钮)。当前快速连接未列 ET;保存主机后开启 ET 再连即可,故仅为便利项。
|
||||
- [ ] **产出二进制**:手动 `workflow_dispatch` 跑 `build-et-binaries.yml`(带
|
||||
`release_tag=et-bin-<ver>-1`)发布到 `Netcatty-et-bin`,并配 `ET_BIN_RELEASE_TOKEN`
|
||||
secret。之后 `ET_BIN_RELEASE=... npm run fetch:et` 即可本地/打包捆绑 `et`。
|
||||
build-et 脚本本机无法编译 C++,需在 CI 验证。
|
||||
- [ ] **端到端冒烟**:有二进制后 `npm run dev`,对装有 etserver 的主机建 ET 会话验证。
|
||||
|
||||
- 当前分支:`feat/et-history-reapply`(基于上游 `031bf0ee`)
|
||||
- 旧 ET 实现参考分支:`feat/eternal-terminal`(tip `67e81616`,7 个 ET 提交)
|
||||
@@ -73,7 +73,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
@@ -82,7 +82,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -287,7 +287,7 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
|
||||
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
||||
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
@@ -633,7 +633,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
@@ -686,7 +686,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
@@ -695,7 +695,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -766,9 +766,10 @@ export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: Hos
|
||||
if (protocolSelectHost) {
|
||||
const hostWithProtocol: Host = {
|
||||
...protocolSelectHost,
|
||||
protocol: protocol === 'mosh' ? 'ssh' : protocol,
|
||||
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
|
||||
port,
|
||||
moshEnabled: protocol === 'mosh',
|
||||
etEnabled: protocol === 'et',
|
||||
};
|
||||
handleConnectToHost(hostWithProtocol);
|
||||
setProtocolSelectHost(null);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
@@ -72,31 +72,34 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return;
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
@@ -269,6 +272,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Context window',
|
||||
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
@@ -49,7 +53,7 @@ export const enAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
@@ -83,6 +87,9 @@ export const enAiMessages: Messages = {
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.settings': 'Settings file',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
@@ -90,7 +97,7 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
@@ -105,7 +112,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
@@ -198,21 +205,21 @@ export const enAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
'ai.safety.note': 'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
|
||||
@@ -161,6 +161,17 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
|
||||
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
|
||||
'settings.sshDebugLogs.location': 'Log Location',
|
||||
'settings.sshDebugLogs.status': 'Status',
|
||||
'settings.sshDebugLogs.statusOn': 'On',
|
||||
'settings.sshDebugLogs.statusOff': 'Off',
|
||||
'settings.sshDebugLogs.size': 'Size',
|
||||
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
@@ -227,6 +238,8 @@ export const enCoreMessages: Messages = {
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.needsSave.title': 'Unsaved Changes',
|
||||
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
@@ -394,6 +407,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.customArgs': 'Launch arguments',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': 'e.g. --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': 'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
@@ -557,12 +573,12 @@ export const enCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP, SOCKS5, or command proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.required': 'Name and proxy details are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
|
||||
@@ -104,6 +104,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in Netcatty.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.hostKey.unknownTitle': 'Confirm this host key',
|
||||
@@ -514,6 +517,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': 'Select or create package',
|
||||
'snippets.field.createPackage': 'Create Package',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Open in dialog',
|
||||
'snippets.scriptEditor.resize': 'Resize editor height',
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.history.title': 'Shell History',
|
||||
|
||||
@@ -275,6 +275,7 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWithDefault': 'Open with system default',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -471,6 +472,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -483,6 +485,9 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'ET server port',
|
||||
'hostDetails.et.port.desc': 'Port etserver listens on (default 2022)',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
'hostDetails.password.show': 'Show password',
|
||||
@@ -542,12 +547,15 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Use %h for the target host, %p for the target port, and %% for a literal percent.',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
@@ -558,7 +566,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
|
||||
@@ -34,6 +34,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Контекстное окно',
|
||||
'ai.providers.contextWindow.placeholder': 'например, 128000',
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
@@ -49,7 +53,7 @@ export const ruAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Использует codex + codex-acp для потоковой передачи по протоколу ACP. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.detecting': 'Обнаружение...',
|
||||
'ai.codex.notFound': 'Не найден',
|
||||
'ai.codex.awaitingLogin': 'Ожидание входа',
|
||||
@@ -83,6 +87,9 @@ export const ruAiMessages: Messages = {
|
||||
'ai.claude.configDir': 'Каталог конфигурации',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
|
||||
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
|
||||
'ai.claude.settings': 'Файл настроек',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
|
||||
'ai.claude.envVars': 'Переменные окружения',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
@@ -90,7 +97,7 @@ export const ruAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI через ACP по stdio (`copilot --acp --stdio`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.detecting': 'Обнаружение...',
|
||||
'ai.copilot.detected': 'Обнаружен',
|
||||
'ai.copilot.notFound': 'Не найден',
|
||||
@@ -105,7 +112,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty (встроенный)',
|
||||
'ai.toolAccess.title': 'Доступ к инструментам',
|
||||
'ai.toolAccess.mode': 'Режим доступа Netcatty',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние ACP-агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'Пользовательские skills',
|
||||
@@ -198,21 +205,21 @@ export const ruAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Безопасность',
|
||||
'ai.safety.permissionMode': 'Режим разрешений',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к ACP-агентам. Режим подтверждения носит рекомендательный характер для ACP-агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
|
||||
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
|
||||
'ai.safety.permissionMode.autonomous': 'Автономный — выполнять свободно',
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к ACP-агентам.',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У ACP-агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к ACP-агентам через механизм выполнения Netcatty.',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
|
||||
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
|
||||
'ai.safety.blocklist.add': 'Добавить шаблон',
|
||||
'ai.safety.note': 'Чёрный список команд, тайм-аут команд и режим наблюдателя применяются на уровне MCP Server ко всем типам агентов. Режим подтверждения и максимальное число итераций полностью применяются к встроенному агенту; у ACP-агентов могут быть свои внутренние механизмы управления этими настройками.',
|
||||
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
|
||||
@@ -161,6 +161,17 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
|
||||
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
|
||||
'settings.sshDebugLogs.location': 'Расположение журнала',
|
||||
'settings.sshDebugLogs.status': 'Статус',
|
||||
'settings.sshDebugLogs.statusOn': 'Включено',
|
||||
'settings.sshDebugLogs.statusOff': 'Отключено',
|
||||
'settings.sshDebugLogs.size': 'Размер',
|
||||
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
|
||||
'settings.globalHotkey.toggleWindow': 'Переключение окна',
|
||||
@@ -227,6 +238,8 @@ export const ruCoreMessages: Messages = {
|
||||
'update.restartNow': 'Перезапустить сейчас',
|
||||
'update.downloadFailed.title': 'Ошибка обновления',
|
||||
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
|
||||
'update.needsSave.title': 'Несохранённые изменения',
|
||||
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
|
||||
'update.openReleases': 'Открыть релизы',
|
||||
'update.remindLater': 'Напомнить позже',
|
||||
'update.skipVersion': 'Пропустить эту версию',
|
||||
@@ -394,6 +407,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
|
||||
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
|
||||
'settings.terminal.localShell.shell.customArgs': 'Аргументы запуска',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': 'напр. --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': 'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
|
||||
'settings.terminal.localShell.startDir': 'Начальный каталог',
|
||||
@@ -594,12 +610,12 @@ export const ruCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': 'Прокси',
|
||||
'proxyProfiles.count.items': 'Элементов: {count}',
|
||||
'proxyProfiles.empty.title': 'Нет прокси',
|
||||
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP- или SOCKS5-прокси и выбирайте их в настройках хоста.',
|
||||
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP-, SOCKS5- или командные прокси и выбирайте их в настройках хоста.',
|
||||
'proxyProfiles.usage': 'Связано: {count}',
|
||||
'proxyProfiles.copyName': '{name} Копия',
|
||||
'proxyProfiles.panel.newTitle': 'Новый прокси',
|
||||
'proxyProfiles.field.name': 'Имя прокси',
|
||||
'proxyProfiles.error.required': 'Имя, хост и порт обязательны.',
|
||||
'proxyProfiles.error.required': 'Имя и параметры прокси обязательны.',
|
||||
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
|
||||
'proxyProfiles.viewMode': 'Режим просмотра прокси',
|
||||
'proxyProfiles.delete.title': 'Удалить прокси?',
|
||||
|
||||
@@ -532,6 +532,20 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
|
||||
'snippets.field.createPackage': 'Создать пакет',
|
||||
'snippets.field.scriptRequired': 'Скрипт *',
|
||||
'snippets.scriptEditor.expand': 'Открыть в окне',
|
||||
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
|
||||
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
|
||||
'snippets.variables.dialogTitle': 'Переменные сниппета',
|
||||
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
|
||||
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
|
||||
'snippets.variables.preview': 'Предпросмотр',
|
||||
'snippets.variables.placeholder': 'Введите значение',
|
||||
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
|
||||
'snippets.variables.required': 'Эта переменная обязательна',
|
||||
'snippets.variables.run': 'Запустить',
|
||||
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
|
||||
'snippets.field.variablesDetected': 'Переменные',
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
|
||||
@@ -310,6 +310,7 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Копировать путь к файлу',
|
||||
'sftp.context.openWithDefault': 'Открыть в системном приложении',
|
||||
'sftp.context.openWith': 'Открыть с помощью...',
|
||||
'sftp.context.edit': 'Редактировать',
|
||||
'sftp.context.preview': 'Предпросмотр',
|
||||
@@ -506,6 +507,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -577,12 +579,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Напрямую',
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Используйте %h для целевого хоста, %p для целевого порта и %% для символа процента.',
|
||||
'hostDetails.proxyPanel.credentials': 'Учётные данные',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
|
||||
@@ -593,7 +598,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
|
||||
'hostDetails.proxyPanel.missing': 'Отсутствует',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
|
||||
'hostDetails.proxyPanel.error.required': 'Прокси-хост и порт обязательны.',
|
||||
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
|
||||
'hostDetails.envVars': 'Переменные окружения',
|
||||
'hostDetails.envVars.add': 'Добавить переменную окружения',
|
||||
'hostDetails.envVars.title': 'Переменные окружения',
|
||||
|
||||
@@ -34,6 +34,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文窗口',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
@@ -49,7 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
@@ -83,6 +87,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.settings': 'Settings 文件',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json(路径,或内联 {"model":"..."})',
|
||||
'ai.claude.settings.hint': '可选。settings.json 路径或内联 JSON,作为 SDK 的 `settings` 传入。与上面的「配置目录」互补且独立(叠加合并,不是替换)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
@@ -90,7 +97,7 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.description': '接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
@@ -105,7 +112,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
@@ -198,21 +205,21 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 通过 Netcatty 访问终端会话的方式。观察者模式会阻止经由 Netcatty 的写操作;外部 Agent CLI 可能仍有自己的本机工具和审批流程。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.description': '用于拦截通过 Netcatty 执行的危险命令的正则表达式。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
'ai.safety.note': '这些安全设置会约束经由 Netcatty 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
|
||||
@@ -145,6 +145,17 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
'settings.sshDebugLogs.enable': '启用 SSH 调试日志',
|
||||
'settings.sshDebugLogs.enableDesc': '记录连接、认证、握手、断开和错误原因,不记录终端输出。',
|
||||
'settings.sshDebugLogs.location': '日志位置',
|
||||
'settings.sshDebugLogs.status': '状态',
|
||||
'settings.sshDebugLogs.statusOn': '已开启',
|
||||
'settings.sshDebugLogs.statusOff': '未开启',
|
||||
'settings.sshDebugLogs.size': '大小',
|
||||
'settings.sshDebugLogs.hint': '开启后,新发起的 SSH 连接会写入诊断信息,方便排查堡垒机、认证和异常断开问题。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
@@ -211,6 +222,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.needsSave.title': '有未保存内容',
|
||||
'update.needsSave.message': '请先保存已打开的编辑器,然后再次点击「立即重启」以安装更新。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
@@ -336,12 +349,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 项',
|
||||
'proxyProfiles.empty.title': '暂无代理',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP、SOCKS5 或命令代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.usage': '已关联 {count} 处',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名称',
|
||||
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
|
||||
'proxyProfiles.error.required': '名称和代理详情不能为空。',
|
||||
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
|
||||
'proxyProfiles.viewMode': '代理显示方式',
|
||||
'proxyProfiles.delete.title': '删除代理?',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -253,6 +256,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.customArgs': '启动参数',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': '传给 Shell 的启动参数。部分 Shell 必须指定才能正常工作,例如 msys2 bash 需要 --login -i 才能加载环境变量。',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
@@ -340,8 +346,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目标主机,%p 表示目标端口,%% 表示字面百分号。',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
@@ -352,7 +361,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
@@ -495,6 +504,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': '选择或创建代码包',
|
||||
'snippets.field.createPackage': '创建代码包',
|
||||
'snippets.field.scriptRequired': '脚本 *',
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.history.title': 'Shell 历史',
|
||||
|
||||
@@ -65,6 +65,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里云 Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
@@ -77,6 +78,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.distro.option.ruijie': '锐捷',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'ET 服务端口',
|
||||
'hostDetails.et.port.desc': 'etserver 监听端口(默认 2022)',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
'hostDetails.password.show': '显示密码',
|
||||
@@ -136,7 +140,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -599,6 +603,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'sftp.context.openWithDefault': '系统默认程序打开',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -131,7 +131,7 @@ test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use,
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
// orphaned list, otherwise the active chat loses external agent continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
@@ -42,11 +42,11 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupAcpSessions(sessionIds: string[]) {
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
|
||||
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal file
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAiSidePanelToggleIntent } from "./resolveAiSidePanelToggleIntent.ts";
|
||||
|
||||
test("close: AI panel already open → close the side panel", () => {
|
||||
const r = resolveAiSidePanelToggleIntent("ai");
|
||||
assert.deepEqual(r, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("open: no panel open → open AI", () => {
|
||||
const r = resolveAiSidePanelToggleIntent(null);
|
||||
assert.deepEqual(r, { kind: "openAi" });
|
||||
});
|
||||
|
||||
test("open: a different sub-panel is open → switch to AI", () => {
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("sftp"), { kind: "openAi" });
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("scripts"), { kind: "openAi" });
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("theme"), { kind: "openAi" });
|
||||
});
|
||||
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal file
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type AiSidePanelToggleIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openAi' };
|
||||
|
||||
/**
|
||||
* Decide what the top-bar AI button should do given the side panel that is
|
||||
* currently open for the active tab.
|
||||
* - If the AI panel is already the open sub-panel → close the whole side panel.
|
||||
* - Otherwise (closed, or showing a different sub-panel) → switch to AI.
|
||||
*/
|
||||
export function resolveAiSidePanelToggleIntent(
|
||||
activePanel: string | null,
|
||||
): AiSidePanelToggleIntent {
|
||||
if (activePanel === 'ai') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openAi' };
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export const createHostTerminalSession = (
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
@@ -51,6 +52,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -78,6 +80,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -164,6 +167,9 @@ export function useSettingsIpcSync({
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -216,6 +222,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpTransferConcurrencyState,
|
||||
|
||||
@@ -63,6 +63,7 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
// Session Logs defaults
|
||||
export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -155,4 +156,3 @@ export const applyThemeTokens = (
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -73,6 +74,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
@@ -103,6 +105,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
@@ -118,7 +121,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -127,7 +130,7 @@ export function useSettingsStorageSync({
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -141,7 +144,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
@@ -151,7 +154,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
|
||||
@@ -302,6 +305,12 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDebugLogsEnabled) {
|
||||
setSshDebugLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -387,6 +396,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpAutoSync,
|
||||
setSftpDefaultViewMode,
|
||||
|
||||
@@ -71,7 +71,7 @@ export const useSftpConnections = ({
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -207,6 +207,11 @@ export const useSftpConnections = ({
|
||||
isLocal: false,
|
||||
status: "connecting",
|
||||
currentPath: cachedStartPath,
|
||||
// Suppress loading animation when connection reuse is requested.
|
||||
// If the backend falls back to a fresh connection, the pane stays
|
||||
// non-interactive (loading=true) with stale cached files visible —
|
||||
// no worse than the previous UX of always showing a spinner.
|
||||
reusedConnection: !!options?.sourceSessionId,
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
@@ -292,6 +297,7 @@ export const useSftpConnections = ({
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
@@ -302,6 +308,7 @@ export const useSftpConnections = ({
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
@@ -317,6 +324,7 @@ export const useSftpConnections = ({
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -452,6 +460,7 @@ export const useSftpConnections = ({
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
reusedConnection: undefined,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
import { joinPath } from "./utils";
|
||||
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
|
||||
import {
|
||||
@@ -178,27 +179,24 @@ export const useSftpExternalOperations = (
|
||||
[getPaneByConnectionId, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
const downloadToTemp = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
): Promise<{ localTempPath: string; sftpId: string; externalTransferId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
if (!bridge?.downloadSftpToTemp) {
|
||||
throw new Error("SFTP temp download not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
throw new Error("Temp download is only available for remote files");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
@@ -287,12 +285,12 @@ export const useSftpExternalOperations = (
|
||||
if (localTempPath && bridge.deleteTempFile) {
|
||||
bridge.deleteTempFile(localTempPath).catch(() => {});
|
||||
}
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
updateExternalUpload(externalTransferId, {
|
||||
@@ -311,7 +309,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
@@ -322,11 +320,44 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
}
|
||||
|
||||
return { localTempPath, sftpId, externalTransferId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
}
|
||||
|
||||
const { localTempPath, sftpId, externalTransferId } = await downloadToTemp(side, remotePath, fileName);
|
||||
if (!localTempPath) {
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
updateExternalUpload?.(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -354,7 +385,67 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
[downloadToTemp, getActivePane, updateExternalUpload],
|
||||
);
|
||||
|
||||
const openWithSystemDefault = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openWithSystemDefault) {
|
||||
throw new Error("System default opening not supported");
|
||||
}
|
||||
|
||||
const bridgeMethods = bridge;
|
||||
|
||||
const { localTempPath, sftpId, externalTransferId } = pane.connection.isLocal
|
||||
? { localTempPath: remotePath, sftpId: "", externalTransferId: undefined }
|
||||
: await downloadToTemp(side, remotePath, fileName);
|
||||
|
||||
if (!localTempPath) return;
|
||||
|
||||
const result = await bridgeMethods.openWithSystemDefault(localTempPath);
|
||||
if (!result.success) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload?.(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: result.error || "Failed to open file",
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
throw new Error(result.error || "Failed to open file");
|
||||
}
|
||||
|
||||
// Start file watch for remote SFTP auto-sync (mirrors downloadToTempAndOpen behavior)
|
||||
if (options?.enableWatch && !pane.connection.isLocal && bridgeMethods.startFileWatch) {
|
||||
try {
|
||||
await bridgeMethods.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
activeFileWatchCountRef.current += 1;
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch for default app open:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
|
||||
}
|
||||
},
|
||||
[downloadToTemp, getActivePane, updateExternalUpload],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -914,6 +1005,7 @@ export const useSftpExternalOperations = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface SftpExternalOperationsResult {
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
@@ -62,4 +63,3 @@ export interface SftpExternalOperationsResult {
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,31 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.proxy, {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/m
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
|
||||
|
||||
// Fallback used when no global TerminalSettings are wired through (older
|
||||
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
|
||||
@@ -36,6 +37,7 @@ export const buildSftpHostCredentials = ({
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
command: host.proxyConfig.command,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
@@ -69,7 +71,7 @@ export const buildSftpHostCredentials = ({
|
||||
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
@@ -101,11 +103,12 @@ export const buildSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
command: jumpHost.proxyConfig.command,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
|
||||
62
application/state/terminalConnectionReuse.test.ts
Normal file
62
application/state/terminalConnectionReuse.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import {
|
||||
canReuseTerminalConnection,
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Host",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("connected SSH sessions can reuse their authenticated connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session()), true);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "local" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "serial" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "telnet" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ moshEnabled: true })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("split session clones reuse only connected SSH sources", () => {
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session(), { id: "split-1", workspaceId: "workspace-1" }).reuseConnectionFromSessionId,
|
||||
"session-1",
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ etEnabled: true }), { id: "split-2" }).reuseConnectionFromSessionId,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ moshEnabled: true }), { id: "split-3" }).reuseConnectionFromSessionId,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
const copied = createCopiedTerminalSessionClone(
|
||||
session({
|
||||
serialConfig: { path: "/dev/tty.usbserial", baudRate: 115200 },
|
||||
}),
|
||||
{ id: "copy-1" },
|
||||
);
|
||||
|
||||
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
|
||||
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
|
||||
});
|
||||
71
application/state/terminalConnectionReuse.ts
Normal file
71
application/state/terminalConnectionReuse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined) &&
|
||||
!session.moshEnabled &&
|
||||
!session.etEnabled &&
|
||||
session.status === "connected"
|
||||
);
|
||||
}
|
||||
|
||||
type CloneSessionOptions = {
|
||||
id: string;
|
||||
localShellType?: TerminalSession["shellType"];
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
return session.protocol === "local" ? localShellType : session.shellType;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
const clonedSession: TerminalSession = {
|
||||
id: options.id,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: "connecting",
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
etEnabled: session.etEnabled,
|
||||
shellType: getClonedShellType(session, options.localShellType),
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
if (options.workspaceId) {
|
||||
clonedSession.workspaceId = options.workspaceId;
|
||||
}
|
||||
|
||||
return clonedSession;
|
||||
}
|
||||
|
||||
export function createSplitTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
return createTerminalSessionClone(session, options);
|
||||
}
|
||||
|
||||
export function createCopiedTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
return {
|
||||
...createTerminalSessionClone(session, options),
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,101 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("uploads path-backed clipboard files through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
|
||||
const taskTotals: number[] = [];
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
startStreamTransfer: async (payload) => {
|
||||
transfers.push({
|
||||
sourcePath: payload.sourcePath,
|
||||
targetPath: payload.targetPath,
|
||||
totalBytes: payload.totalBytes,
|
||||
});
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onTaskCreated: (task) => taskTotals.push(task.totalBytes),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(taskTotals, [42]);
|
||||
assert.deepEqual(transfers, [
|
||||
{
|
||||
sourcePath: "/Users/me/Desktop/report.txt",
|
||||
targetPath: "/target/report.txt",
|
||||
totalBytes: 42,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "report.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("copies path-backed clipboard files into local panes through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; targetType: string; totalBytes?: number }> = [];
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirLocal: async () => {},
|
||||
startStreamTransfer: async (payload) => {
|
||||
transfers.push({
|
||||
sourcePath: payload.sourcePath,
|
||||
targetPath: payload.targetPath,
|
||||
targetType: payload.targetType,
|
||||
totalBytes: payload.totalBytes,
|
||||
});
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(transfers, [
|
||||
{
|
||||
sourcePath: "/Users/me/Desktop/report.txt",
|
||||
targetPath: "/target/report.txt",
|
||||
targetType: "local",
|
||||
totalBytes: 42,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "report.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports empty directory creation failures", async () => {
|
||||
const madeDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
bumpDraftMutationVersion,
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupAcpSessions,
|
||||
cleanupSdkAgentSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
@@ -321,7 +321,7 @@ export function useAIState() {
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so ACP agents also respect the blocklist
|
||||
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
@@ -337,7 +337,7 @@ export function useAIState() {
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
// Sync to MCP Server bridge (used by ACP agent path)
|
||||
// Sync to MCP Server bridge (used by SDK agent path)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
@@ -571,7 +571,7 @@ export function useAIState() {
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupAcpSessions([sessionId]);
|
||||
cleanupSdkAgentSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -600,7 +600,7 @@ export function useAIState() {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
cleanupSdkAgentSessions(removedSessionIds);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
@@ -52,19 +53,23 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
// Check if args, SDK backend, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
const backend = match.sdkBackend ?? match.command;
|
||||
const backendChanged = getExternalAgentSdkBackend(ea) !== backend
|
||||
|| Boolean(ea.acpCommand)
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify([]);
|
||||
const matchPath = match.binPath || match.path;
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
|
||||
return { ...rest, args: match.args, sdkBackend: backend, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -82,16 +87,18 @@ export function useAgentDiscovery(
|
||||
// Build ExternalAgentConfig from a discovered agent
|
||||
const enableAgent = useCallback(
|
||||
(agent: DiscoveredAgent): ExternalAgentConfig => {
|
||||
const backend = agent.sdkBackend ?? agent.command;
|
||||
return {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
command: agent.path || agent.command,
|
||||
command: agent.binPath || agent.path || agent.command,
|
||||
args: agent.args,
|
||||
icon: agent.icon,
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
sdkBackend: backend,
|
||||
...(agent.command === 'claude'
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -17,6 +17,10 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
} from './terminalConnectionReuse';
|
||||
|
||||
|
||||
export const useSessionState = () => {
|
||||
@@ -286,6 +290,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
@@ -372,6 +377,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
@@ -475,6 +481,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
workspaceId,
|
||||
};
|
||||
@@ -574,31 +581,15 @@ export const useSessionState = () => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// If session is already in a workspace, split within that workspace
|
||||
if (session.workspaceId) {
|
||||
// Create a new session with the same host
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
localShellType: options?.localShellType,
|
||||
workspaceId: session.workspaceId,
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
});
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
@@ -618,23 +609,10 @@ export const useSessionState = () => {
|
||||
}
|
||||
|
||||
// Session is standalone - create a new workspace
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -802,28 +780,10 @@ export const useSessionState = () => {
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createCopiedTerminalSessionClone(session, {
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
applyThemeTokens,
|
||||
@@ -240,6 +242,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -460,6 +466,12 @@ export const useSettingsState = () => {
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SSH diagnostics
|
||||
const storedSshDebugLogsEnabled = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
|
||||
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
|
||||
}
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
@@ -552,6 +564,7 @@ export const useSettingsState = () => {
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -587,7 +600,7 @@ export const useSettingsState = () => {
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -596,7 +609,7 @@ export const useSettingsState = () => {
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -779,6 +792,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
|
||||
}, [sshDebugLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
@@ -940,6 +959,8 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -963,7 +984,7 @@ export const useSettingsState = () => {
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle,
|
||||
customThemes, workspaceFocusStyle, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -304,6 +304,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -383,6 +384,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -440,6 +442,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -507,6 +510,8 @@ export const useSftpState = (
|
||||
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
openWithSystemDefault: (...args: Parameters<typeof openWithSystemDefault>) =>
|
||||
methodsRef.current.openWithSystemDefault(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
|
||||
methodsRef.current.uploadExternalFileList(...args),
|
||||
|
||||
@@ -12,6 +12,11 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startMoshSession;
|
||||
}, []);
|
||||
|
||||
const etAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startEtSession;
|
||||
}, []);
|
||||
|
||||
const localAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startLocalSession;
|
||||
@@ -45,6 +50,12 @@ export const useTerminalBackend = () => {
|
||||
return bridge.startMoshSession(options);
|
||||
}, []);
|
||||
|
||||
const startEtSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startEtSession) throw new Error("startEtSession unavailable");
|
||||
return bridge.startEtSession(options);
|
||||
}, []);
|
||||
|
||||
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
|
||||
@@ -116,6 +127,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onChainProgress?.(cb);
|
||||
}, []);
|
||||
|
||||
const onConnectionReuseFallback = useCallback((cb: (sessionId: string, sourceSessionId?: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onConnectionReuseFallback?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -196,6 +212,7 @@ export const useTerminalBackend = () => {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
@@ -203,6 +220,7 @@ export const useTerminalBackend = () => {
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
@@ -221,6 +239,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -229,6 +248,7 @@ export const useTerminalBackend = () => {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
@@ -236,6 +256,7 @@ export const useTerminalBackend = () => {
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
@@ -254,6 +275,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
@@ -53,13 +53,20 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
||||
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
||||
|
||||
// Latest "install blocked by unsaved editors" callback (#1215). Kept in a ref
|
||||
// so the listener effect (empty deps) always calls the current one without
|
||||
// re-subscribing on every render. The consuming component shows the toast;
|
||||
// this hook only owns the bridge subscription (toasts live in the view layer).
|
||||
const onNeedsSaveRef = useRef(options?.onNeedsSave);
|
||||
onNeedsSaveRef.current = options?.onNeedsSave;
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -252,12 +259,22 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
}));
|
||||
});
|
||||
|
||||
// Install was requested but blocked by unsaved editors (#1215). The main
|
||||
// process broadcasts this to every window so whichever one the user clicked
|
||||
// "Restart Now" from gets feedback. Delegate to the caller's handler (which
|
||||
// shows the toast) — registered here because bridge subscriptions belong in
|
||||
// the state layer, not in components.
|
||||
const cleanupNeedsSave = bridge?.onUpdateNeedsSave?.(() => {
|
||||
onNeedsSaveRef.current?.();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNotAvailable?.();
|
||||
cleanupAvailable?.();
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
cleanupNeedsSave?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ export const useWindowControls = () => {
|
||||
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowCommandCloseRequested?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifyRendererReady,
|
||||
closeSettingsWindow,
|
||||
@@ -60,5 +65,6 @@ export const useWindowControls = () => {
|
||||
isMaximized,
|
||||
isFullscreen,
|
||||
onFullscreenChanged,
|
||||
onWindowCommandCloseRequested,
|
||||
};
|
||||
};
|
||||
|
||||
69
application/state/windowCommandClose.test.ts
Normal file
69
application/state/windowCommandClose.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
|
||||
|
||||
test("Cmd+W closes the active closable tab first", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1", "s2"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeTab" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W on a log view closes the log view", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1", "s2"],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
}),
|
||||
{ kind: "closeLogView", tabId: "log-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes an editor tab through the existing close flow", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "editor:1",
|
||||
editorTabIds: ["editor:1"],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeTab" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes the window from the Vault page", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeWindow" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes the window when nothing else is active", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: null,
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeWindow" },
|
||||
);
|
||||
});
|
||||
42
application/state/windowCommandClose.ts
Normal file
42
application/state/windowCommandClose.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type WindowCommandCloseIntent =
|
||||
| { kind: 'closeTab' }
|
||||
| { kind: 'closeLogView'; tabId: string }
|
||||
| { kind: 'closeWindow' };
|
||||
|
||||
interface ResolveWindowCommandCloseIntentInput {
|
||||
activeTabId: string | null;
|
||||
editorTabIds: string[];
|
||||
sessionIds: string[];
|
||||
workspaceIds: string[];
|
||||
logViewIds: string[];
|
||||
}
|
||||
|
||||
export function resolveWindowCommandCloseIntent({
|
||||
activeTabId,
|
||||
editorTabIds,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
logViewIds,
|
||||
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
|
||||
if (!activeTabId) {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
if (editorTabIds.includes(activeTabId)) {
|
||||
return { kind: 'closeTab' };
|
||||
}
|
||||
|
||||
if (sessionIds.includes(activeTabId) || workspaceIds.includes(activeTabId)) {
|
||||
return { kind: 'closeTab' };
|
||||
}
|
||||
|
||||
if (logViewIds.includes(activeTabId)) {
|
||||
return { kind: 'closeLogView', tabId: activeTabId };
|
||||
}
|
||||
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
@@ -480,16 +480,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
if (!bridge?.aiSdkAgentListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
void bridge.aiSdkAgentListModels(
|
||||
sdkBackend,
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
@@ -515,7 +515,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -744,7 +744,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
@@ -811,7 +811,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
@@ -18,7 +19,7 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
getExternalAgentSdkBackend(agent),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
|
||||
@@ -18,6 +18,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
oracle: "/distro/oracle.svg",
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
@@ -48,6 +49,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
oracle: "bg-[#C74634]",
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
|
||||
@@ -13,7 +13,12 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -103,6 +108,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
const hasTelnetFields = (c: Partial<GroupConfig>) =>
|
||||
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
|
||||
@@ -137,7 +143,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
||||
|
||||
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -171,6 +177,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.protocol;
|
||||
delete next.moshEnabled;
|
||||
delete next.moshServerPath;
|
||||
delete next.etEnabled;
|
||||
delete next.etPort;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -391,6 +399,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
|
||||
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
|
||||
...(form.moshServerPath !== undefined && { moshServerPath: form.moshServerPath }),
|
||||
...(form.etEnabled !== undefined && { etEnabled: form.etEnabled }),
|
||||
...(form.etPort !== undefined && { etPort: form.etPort }),
|
||||
}),
|
||||
// Only include Telnet fields if Telnet section is enabled
|
||||
...(telnetEnabled && {
|
||||
|
||||
@@ -449,7 +449,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
{(form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
@@ -523,6 +523,25 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EternalTerminal */}
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => update("etEnabled", !form.etEnabled)}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hostDetails.et.port") || "ET server port (2022)"}
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
|
||||
@@ -175,6 +175,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
etEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
@@ -185,6 +186,46 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.et")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.etEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
etEnabled: true,
|
||||
moshEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("etEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder="2022"
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
@@ -212,7 +253,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -226,8 +267,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
@@ -481,7 +522,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,6 +5,40 @@ import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
|
||||
export const resolvePrimaryProtocolSwitchPort = (
|
||||
currentPort: number | undefined,
|
||||
nextProtocol: "ssh" | "telnet",
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (nextProtocol === "telnet") {
|
||||
// Don't override if group provides a Telnet default
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 22 || currentPort === undefined) return 23;
|
||||
return currentPort;
|
||||
}
|
||||
if (nextProtocol === "ssh") {
|
||||
if (hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 23 || currentPort === undefined) return 22;
|
||||
return currentPort;
|
||||
}
|
||||
return currentPort;
|
||||
};
|
||||
|
||||
export const resolvePrimaryProtocolSavePort = (
|
||||
protocol: Host["protocol"],
|
||||
currentPort: number | undefined,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (protocol === "telnet") {
|
||||
if (currentPort !== undefined) return currentPort;
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return undefined;
|
||||
return 23;
|
||||
}
|
||||
return currentPort ?? (hasGroupSshPortDefault ? undefined : 22);
|
||||
};
|
||||
|
||||
export const resolveDetailsTelnetPort = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
|
||||
@@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import {
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
@@ -67,6 +71,24 @@ test("HostDetailsPanel shows a missing saved proxy without undefined fields", ()
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
@@ -241,6 +263,26 @@ test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2222, "telnet", false, false), 2222);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2323, "ssh", false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, true), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", true, false), 22);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSavePort falls back to telnet default for primary telnet", () => {
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", 2323, false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, true, false), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, true), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, true, false), undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
getEffectiveHostDistro,
|
||||
normalizePrimaryTelnetState,
|
||||
} from "../domain/host";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
hasHostFontSizeOverride,
|
||||
@@ -36,7 +41,15 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import { LINUX_DISTRO_OPTION_IDS, parseOptionalPortInput, resolveDetailsTelnetPassword, resolveDetailsTelnetPort, resolveDetailsTelnetUsername } from "./HostDetailsPanel.helpers";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
resolveDetailsTelnetPassword,
|
||||
resolveDetailsTelnetPort,
|
||||
resolveDetailsTelnetUsername,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
@@ -246,17 +259,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
|
||||
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
: formatProxyConfigEndpoint(form.proxyConfig);
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
|
||||
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -385,10 +398,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
|
||||
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
||||
const finalPort =
|
||||
form.protocol === "telnet"
|
||||
? form.port
|
||||
: form.port ?? (groupDefaults?.port ? undefined : 22);
|
||||
const finalPort = resolvePrimaryProtocolSavePort(
|
||||
form.protocol,
|
||||
form.port,
|
||||
Boolean(groupDefaults?.port),
|
||||
Boolean(groupDefaults?.telnetPort),
|
||||
);
|
||||
let cleaned: Host = {
|
||||
...formWithoutProxyDraft,
|
||||
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
||||
@@ -443,7 +458,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
@@ -873,7 +888,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
|
||||
<Switch
|
||||
checked={form.protocol === "telnet"}
|
||||
onCheckedChange={(checked) => update("protocol", checked ? "telnet" : "ssh")}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextProtocol = checked ? "telnet" : "ssh";
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
protocol: nextProtocol,
|
||||
port: resolvePrimaryProtocolSwitchPort(
|
||||
prev.port,
|
||||
nextProtocol,
|
||||
Boolean(groupDefaults?.telnetPort),
|
||||
Boolean(groupDefaults?.port),
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
@@ -838,9 +839,35 @@ echo $3 >> "$FILE"`);
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : panel.type === "view" ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
<AsideActionMenu>
|
||||
{panel.key.publicKey ? (
|
||||
<AsideActionMenuItem
|
||||
icon={<Copy size={14} />}
|
||||
onClick={() => copyPublicKey(panel.key)}
|
||||
>
|
||||
{t("action.copyPublicKey")}
|
||||
</AsideActionMenuItem>
|
||||
) : null}
|
||||
<AsideActionMenuItem
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("action.keyExport")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
icon={<Edit2 size={14} />}
|
||||
onClick={() => openKeyEdit(panel.key)}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
variant="destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => handleDelete(panel.key.id)}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -63,6 +63,18 @@ const ProtocolSelectDialog: React.FC<ProtocolSelectDialogProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// EternalTerminal (if enabled)
|
||||
if (host.etEnabled || host.protocols?.some(p => p.protocol === 'et' && p.enabled)) {
|
||||
options.push({
|
||||
protocol: 'et',
|
||||
port: host.port || 22,
|
||||
label: 'EternalTerminal',
|
||||
icon: <Wifi size={18} />,
|
||||
description: `et ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Telnet (if enabled)
|
||||
if (host.telnetEnabled || host.protocol === 'telnet' || host.protocols?.some(p => p.protocol === 'telnet' && p.enabled)) {
|
||||
const telnetConfig = host.protocols?.find(p => p.protocol === 'telnet');
|
||||
|
||||
@@ -49,6 +49,29 @@ test("ProxyPanel shows saved proxy selection when reusable profiles exist", () =
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel labels saved ProxyCommand profiles without showing command contents", () => {
|
||||
const commandProxy: ProxyProfile = {
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [commandProxy],
|
||||
selectedProxyProfileId: commandProxy.id,
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
@@ -78,3 +101,29 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
assert.match(markup, /Port must be between 1 and 65535/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel supports custom ProxyCommand settings", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Command/);
|
||||
assert.match(markup, /cloudflared access ssh --hostname %h/);
|
||||
assert.match(markup, /Use %h for the target host/);
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
assert.doesNotMatch(markup, /Credentials/);
|
||||
});
|
||||
|
||||
test("ProxyPanel uses a dropdown for proxy type selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
|
||||
});
|
||||
|
||||
assert.match(markup, /role="combobox"/);
|
||||
assert.match(markup, /aria-label="Type"/);
|
||||
});
|
||||
|
||||
@@ -40,14 +40,17 @@ const installStorageStub = (viewMode: string | null = null) => {
|
||||
});
|
||||
};
|
||||
|
||||
const renderManager = (viewMode: string | null = null) => {
|
||||
const renderManager = (
|
||||
viewMode: string | null = null,
|
||||
profiles: ProxyProfile[] = [proxyProfile],
|
||||
) => {
|
||||
installStorageStub(viewMode);
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyProfilesManager, {
|
||||
proxyProfiles: [proxyProfile],
|
||||
proxyProfiles: profiles,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
onUpdateProxyProfiles: () => {},
|
||||
@@ -83,3 +86,25 @@ test("ProxyProfilesManager validates proxy ports", () => {
|
||||
assert.equal(isValidProxyPort(65536), false);
|
||||
assert.equal(isValidProxyPort(10.5), false);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", () => {
|
||||
const markup = renderManager(null, [
|
||||
{
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(markup, /aria-label="Cloudflare Access, ProxyCommand, ProxyCommand, 0 linked"/);
|
||||
assert.match(markup, /Cloudflare Access/);
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Globe,
|
||||
@@ -11,12 +10,18 @@ import {
|
||||
Plus,
|
||||
Route,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
removeProxyProfileReferences,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
@@ -47,6 +52,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
@@ -100,8 +106,14 @@ const proxyProtocolMeta = {
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
command: {
|
||||
labelKey: "hostDetails.proxyPanel.command",
|
||||
Icon: SquareTerminal,
|
||||
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label: string;
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
iconClassName: string;
|
||||
}>;
|
||||
@@ -130,8 +142,10 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const protocol = proxyProtocolMeta[profile.config.type];
|
||||
const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label;
|
||||
const ProtocolIcon = protocol.Icon;
|
||||
const accessibleLabel = `${profile.label}, ${protocol.label}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
const endpoint = formatProxyConfigEndpoint(profile.config);
|
||||
const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -154,7 +168,7 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
title={protocol.label}
|
||||
title={protocolLabel}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
@@ -163,8 +177,8 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{profile.config.host}:{profile.config.port} -{" "}
|
||||
{protocol.label}
|
||||
{endpoint} -{" "}
|
||||
{protocolLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +236,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
return proxyProfiles.filter((profile) =>
|
||||
profile.label.toLowerCase().includes(q) ||
|
||||
profile.config.host.toLowerCase().includes(q) ||
|
||||
(profile.config.command || "").toLowerCase().includes(q) ||
|
||||
profile.config.type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [proxyProfiles, search]);
|
||||
@@ -269,11 +284,13 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
if (!label || !host || !draft.config.port) {
|
||||
const command = draft.config.command?.trim() || "";
|
||||
const isCommand = isProxyCommandConfig(draft.config);
|
||||
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
|
||||
toast.error(t("proxyProfiles.error.required"));
|
||||
return;
|
||||
}
|
||||
if (!isValidProxyPort(draft.config.port)) {
|
||||
if (!isCommand && !isValidProxyPort(draft.config.port)) {
|
||||
toast.error(t("proxyProfiles.error.port"));
|
||||
return;
|
||||
}
|
||||
@@ -281,13 +298,20 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
const saved: ProxyProfile = {
|
||||
...draft,
|
||||
label,
|
||||
config: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
config: isCommand
|
||||
? {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command,
|
||||
}
|
||||
: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -446,56 +470,64 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("field.type")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={draft.config.type === "http" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "http")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "socks5")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.config.type}
|
||||
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
|
||||
>
|
||||
<SelectTrigger aria-label={t("field.type")} className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
{isProxyCommandConfig(draft.config) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.proxyPanel.commandHelp")}
|
||||
</p>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
value={draft.config.command || ""}
|
||||
onChange={(event) => updateDraftConfig("command", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
className="h-10 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
@@ -518,7 +550,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</Card>}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button className="w-full" onClick={saveDraft}>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { CodeTextarea } from './ui/code-textarea';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
@@ -148,8 +148,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogContent
|
||||
className="max-w-md max-h-[min(90vh,720px)] flex flex-col overflow-hidden"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
@@ -158,7 +161,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
@@ -174,18 +177,13 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-command" className="text-xs">
|
||||
{t('snippets.field.scriptRequired')}
|
||||
</Label>
|
||||
<CodeTextarea
|
||||
id="quick-add-snippet-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<SnippetScriptEditor
|
||||
id="quick-add-snippet-command"
|
||||
label={t('snippets.field.scriptRequired')}
|
||||
value={command}
|
||||
onChange={setCommand}
|
||||
placeholder="echo hello"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
@@ -203,7 +201,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import { toast } from "./ui/toast";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
@@ -163,8 +164,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
// Install blocked by unsaved editors in the main window — surface a toast
|
||||
// here so a click from the Settings window isn't a silent no-op (#1215).
|
||||
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -172,6 +178,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
notifyRendererReady();
|
||||
}, [notifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
void closeSettingsWindow();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
setMountedTabs((prev) => {
|
||||
if (prev.has(activeTab)) return prev;
|
||||
@@ -353,6 +366,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
|
||||
162
components/SftpClipboardUpload.test.ts
Normal file
162
components/SftpClipboardUpload.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createDropEntriesFromClipboardFiles,
|
||||
getSftpClipboardSystemTextPaths,
|
||||
getSupportedClipboardUploadFiles,
|
||||
isSftpNativeClipboardPasteEnabled,
|
||||
resolveSftpClipboardUploadTarget,
|
||||
shouldLetNativePasteEventHandleSftpPaste,
|
||||
type ClipboardLocalFile,
|
||||
} from "./sftp/clipboardUpload.ts";
|
||||
import type { SftpFileEntry } from "../types";
|
||||
|
||||
const file = (name: string, overrides: Partial<SftpFileEntry> = {}): SftpFileEntry => ({
|
||||
name,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modified: new Date(0),
|
||||
permissions: "-rw-r--r--",
|
||||
owner: "",
|
||||
group: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the file list", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["logs"],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app/logs");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the current directory without a concrete folder selection", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload ignores selected regular files when resolving the target", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["readme.md"],
|
||||
files: [file("readme.md")],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the tree", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [],
|
||||
treeSelection: [{ name: "logs", path: "/var/logs", isDirectory: true }],
|
||||
});
|
||||
|
||||
assert.equal(target, "/var/logs");
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected list paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["one.txt", "nested two.txt"],
|
||||
treeSelection: [],
|
||||
}),
|
||||
["/home/app/one.txt", "/home/app/nested two.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected tree paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["ignored.txt"],
|
||||
treeSelection: [
|
||||
{ name: "logs", path: "/var/logs", isDirectory: true },
|
||||
{ name: "report.txt", path: "/var/report.txt", isDirectory: false },
|
||||
],
|
||||
}),
|
||||
["/var/logs", "/var/report.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("clipboard files become path-backed upload entries", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
];
|
||||
|
||||
assert.deepEqual(createDropEntriesFromClipboardFiles(files), [
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("clipboard upload ignores directories until recursive paste is supported", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
|
||||
];
|
||||
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "⌘ + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Cmd + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "F9"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpCopy", "Ctrl + V"), false);
|
||||
});
|
||||
|
||||
test("native clipboard paste follows SFTP paste shortcut availability", () => {
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("disabled", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Disabled", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "F9", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + Shift + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -49,6 +49,8 @@ interface SftpSidePanelProps {
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
/** The terminal session id whose SSH connection can be reused for SFTP */
|
||||
activeSessionId?: string | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
@@ -83,6 +85,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
@@ -446,12 +449,13 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
s.connect("left", activeHost, {
|
||||
sourceSessionId: activeSessionId ?? undefined,
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
|
||||
}, [activeHost, activeSessionId, hasActiveWork]);
|
||||
|
||||
// Clear the remembered connection key when the pane disconnects or the
|
||||
// session is lost, so re-opening SFTP for the same terminal reconnects.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-pane
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { CodeTextarea } from './ui/code-textarea';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
@@ -165,12 +165,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
|
||||
{/* Script */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.scriptRequired')}</p>
|
||||
<CodeTextarea
|
||||
<SnippetScriptEditor
|
||||
label={t('snippets.field.scriptRequired')}
|
||||
placeholder="ls -l"
|
||||
className="min-h-[120px]"
|
||||
value={editingSnippet.command || ''}
|
||||
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
|
||||
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t('snippets.field.variablesHelp')}
|
||||
|
||||
@@ -73,6 +73,8 @@ import {
|
||||
forceSyncRenderAfterResize,
|
||||
formatNetSpeed,
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
shouldHideConnectingDialogForConnectionReuse,
|
||||
shouldShowTerminalConnectionDialog,
|
||||
type TerminalProps,
|
||||
} from "./terminal/terminalHelpers";
|
||||
|
||||
@@ -99,6 +101,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
reuseConnectionFromSessionId,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
@@ -126,6 +129,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
@@ -229,6 +233,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -425,6 +430,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
if (host.etEnabled) {
|
||||
starters.startEt(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
@@ -568,6 +577,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
reuseConnectionFromSessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
terminalSettings,
|
||||
@@ -605,7 +615,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
|
||||
const isEt = host.protocol === 'et' || host.etEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
return;
|
||||
@@ -629,9 +640,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
useEffect(() => {
|
||||
setConnectionReuseFellBack(false);
|
||||
if (!reuseConnectionFromSessionId) return undefined;
|
||||
|
||||
return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => {
|
||||
if (fallbackSessionId === sessionId) {
|
||||
setConnectionReuseFellBack(true);
|
||||
}
|
||||
});
|
||||
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
||||
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
@@ -878,6 +901,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
@@ -893,6 +917,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionStarters.startTelnet(term);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(term);
|
||||
} else if (host.etEnabled) {
|
||||
sessionStarters.startEt(term);
|
||||
} else {
|
||||
sessionStarters.startSSH(term);
|
||||
}
|
||||
@@ -929,9 +955,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
|
||||
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
|
||||
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
||||
status,
|
||||
isLocalConnection,
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
});
|
||||
|
||||
const {
|
||||
handleDragEnter,
|
||||
|
||||
@@ -29,6 +29,7 @@ const baseProps = {
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
editorWordWrap: false,
|
||||
sshDebugLogsEnabled: false,
|
||||
setEditorWordWrap: () => {},
|
||||
onHotkeyAction: () => {},
|
||||
onUpdateHost: () => {},
|
||||
@@ -118,3 +119,13 @@ test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when SSH debug logging changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, sshDebugLogsEnabled: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import {
|
||||
getSessionActivityIdsToClear,
|
||||
@@ -37,6 +38,7 @@ import { Button } from './ui/button';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
|
||||
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
|
||||
import { terminalLayerAreEqual } from './terminalLayerMemo';
|
||||
import { useTerminalLayerEffects } from './terminalLayer/useTerminalLayerEffects';
|
||||
import { TerminalLayerView } from './terminalLayer/TerminalLayerView';
|
||||
@@ -118,6 +120,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sshDebugLogsEnabled,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
}) => {
|
||||
@@ -170,6 +173,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
// Quick Connect / temporary session — build minimal host from session data
|
||||
@@ -518,11 +522,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
const port = session.port ?? existingHost.port;
|
||||
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
|
||||
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
|
||||
|
||||
if (
|
||||
protocol === existingHost.protocol &&
|
||||
port === existingHost.port &&
|
||||
moshEnabled === existingHost.moshEnabled
|
||||
&& etEnabled === existingHost.etEnabled
|
||||
) {
|
||||
map.set(session.id, existingHost);
|
||||
} else {
|
||||
@@ -531,6 +537,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
protocol,
|
||||
port,
|
||||
moshEnabled,
|
||||
etEnabled,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -555,6 +562,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
tags: [],
|
||||
protocol: fallbackProtocol,
|
||||
moshEnabled: session.moshEnabled,
|
||||
etEnabled: session.etEnabled,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
@@ -657,6 +665,21 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return sftpHostForTab.get(activeTabId) ?? null;
|
||||
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
|
||||
|
||||
const activeTerminalSessionIdForSftp = useMemo((): string | null => {
|
||||
if (!isSftpOpenForCurrentTab || !sftpActiveHost) return null;
|
||||
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
|
||||
if (!sessionId) return null;
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return null;
|
||||
const sessionHost = sessionHostsMap.get(session.id);
|
||||
if (!sessionHost) return null;
|
||||
const sameEndpoint =
|
||||
sessionHost.hostname === sftpActiveHost.hostname &&
|
||||
(sessionHost.port || 22) === (sftpActiveHost.port || 22) &&
|
||||
(sessionHost.username || "root") === (sftpActiveHost.username || "root");
|
||||
return sameEndpoint ? session.id : null;
|
||||
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
|
||||
|
||||
const mountedSftpTabIds = useMemo(
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
@@ -840,11 +863,32 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('theme');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open AI chat side panel
|
||||
// Open AI chat side panel (side-panel rail button: a plain switch that is a
|
||||
// no-op when AI is already the active sub-panel, matching the other rail tabs)
|
||||
const handleOpenAI = useCallback(() => {
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Toggle the AI chat side panel from the top-bar button: open it (or switch
|
||||
// to it from another sub-panel), and close the side panel when AI is already
|
||||
// the open sub-panel. Unlike handleOpenAI (the rail switch), a second click
|
||||
// here dismisses the panel.
|
||||
const handleToggleAiFromTopBar = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const intent = resolveAiSidePanelToggleIntent(
|
||||
sidePanelOpenTabsRef.current.get(tabId) ?? null,
|
||||
);
|
||||
|
||||
if (intent.kind === 'closeTerminalSidePanel') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
@@ -980,8 +1024,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Track previous focusedSessionId to detect changes
|
||||
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleOpenAI, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
|
||||
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
|
||||
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
|
||||
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeTerminalSessionIdForSftp, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, sshDebugLogsEnabled, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
|
||||
};
|
||||
|
||||
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Folder, FolderLock, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
|
||||
import { Folder, FolderLock, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fromEditorTabId, isEditorTabId } from '../application/state/activeTabStore';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
@@ -604,9 +604,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -430,9 +430,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (protocolSelectHost) {
|
||||
const hostWithProtocol: Host = {
|
||||
...protocolSelectHost,
|
||||
protocol: protocol === "mosh" ? "ssh" : protocol,
|
||||
protocol: (protocol === "mosh" || protocol === "et") ? "ssh" : protocol,
|
||||
port,
|
||||
moshEnabled: protocol === "mosh",
|
||||
etEnabled: protocol === "et",
|
||||
};
|
||||
onConnect(hostWithProtocol);
|
||||
setProtocolSelectHost(null);
|
||||
|
||||
61
components/ai-elements/tool-call.test.tsx
Normal file
61
components/ai-elements/tool-call.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { extractDisplayCommand } from './tool-call';
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
// outer quote leaks (the "netcatty: \"" / "netcatty: …md\"" garbage titles).
|
||||
|
||||
test('unwraps a /bin/zsh -lc string wrapper (codex SDK shape)', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: `/bin/zsh -lc 'echo "hi"'` }),
|
||||
'echo "hi"',
|
||||
);
|
||||
});
|
||||
|
||||
test('codex Skills+CLI exec: unwrap shell + netcatty-cli -> remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
});
|
||||
|
||||
test('codex Skills+CLI session subcommand -> friendly title', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" session --session X'`,
|
||||
}),
|
||||
'netcatty: inspect session',
|
||||
);
|
||||
});
|
||||
|
||||
test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: `"/abs/netcatty-tool-cli" exec --session X -- "uptime"` }),
|
||||
'uptime',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
|
||||
test('array shell-wrap shape still unwraps (regression)', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({ command: ['zsh', '-lc', 'ls -la /tmp'] }),
|
||||
'ls -la /tmp',
|
||||
);
|
||||
});
|
||||
|
||||
test('plain command passes through unchanged', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'ls -la /tmp' }), 'ls -la /tmp');
|
||||
});
|
||||
|
||||
test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
@@ -12,8 +12,13 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
*
|
||||
* Different tool surfaces hand us different shapes:
|
||||
* - Netcatty's own `terminal_execute` MCP tool → `{command: "<string>"}`
|
||||
* - Codex `local_shell` (ACP) → `{command: ["zsh","-lc","<full>"]}`
|
||||
* - Claude `Bash` (ACP) → `{command: "<string>"}`
|
||||
* - Codex `local_shell` → `{command: ["zsh","-lc","<full>"]}`
|
||||
* - Codex command_execution (SDK) → `{command: "/bin/zsh -lc '<full>'"}`
|
||||
* - Claude `Bash` → `{command: "<string>"}`
|
||||
*
|
||||
* The SDK form is a STRING that wraps the real command in `<shell> -lc '<full>'`,
|
||||
* so we unwrap that wrapper too (the array branch already did the equivalent) —
|
||||
* otherwise the outer shell quotes leak into the title.
|
||||
*
|
||||
* And under the "Skill + CLI" integration, the agent's shell tool wraps a
|
||||
* call to our internal `netcatty-tool-cli` binary, so the real intent is one
|
||||
@@ -25,7 +30,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
* cares about (the remote command), not Codex's wrapper title which is
|
||||
* just the local path to the CLI binary.
|
||||
*/
|
||||
function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
|
||||
export function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
|
||||
@@ -45,6 +50,15 @@ function extractDisplayCommand(args: Record<string, unknown> | undefined): strin
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unwrap a STRING shell wrapper, e.g. Codex SDK's `/bin/zsh -lc '<full>'`.
|
||||
// The array branch above already extracts the inner command; the string form
|
||||
// (codex command_execution) does not, so strip `<shell> -l?c <quote>…<quote>`
|
||||
// here. Without this the outer quote leaks into the netcatty-cli title below.
|
||||
const strWrap = cmdString.match(
|
||||
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
|
||||
);
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
if (cliIdx >= 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
getExternalAgentSdkBackend,
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
@@ -131,7 +132,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
.filter((agent) => agent.enabled)
|
||||
.filter((agent) => agent.enabled && Boolean(getExternalAgentSdkBackend(agent)))
|
||||
.map(
|
||||
(agent): AgentInfo => ({
|
||||
id: agent.id,
|
||||
|
||||
@@ -92,7 +92,7 @@ interface ChatInputProps {
|
||||
/**
|
||||
* Provider→model two-level picker payload. When provided, replaces the
|
||||
* single-list model dropdown with a provider-aware picker. Used for the
|
||||
* Catty Agent only — external ACP agents (Claude/Codex) keep the
|
||||
* Catty Agent only — external SDK agents (Claude/Codex) keep the
|
||||
* `modelPresets` dropdown because their provider is wired inside the CLI.
|
||||
*/
|
||||
providerSwitcher?: ProviderSwitcherConfig;
|
||||
@@ -390,7 +390,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
// Provider switcher mode (Catty Agent): two-column popover, chip carries
|
||||
// the provider's icon + name + model name. Falls back to the existing
|
||||
// single-list model dropdown for ACP agents.
|
||||
// single-list model dropdown for external SDK agents.
|
||||
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
|
||||
// Resolve to the actually-bound provider only — no `?? providers[0]`
|
||||
// fallback, since a provider that isn't really bound will still hit the
|
||||
|
||||
@@ -323,7 +323,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
|
||||
.map(([id, req]) => {
|
||||
|
||||
@@ -9,14 +9,22 @@ const agents: ExternalAgentConfig[] = [
|
||||
id: 'enabled-agent',
|
||||
name: 'Enabled Agent',
|
||||
command: '/usr/local/bin/enabled-agent',
|
||||
sdkBackend: 'codex',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'disabled-agent',
|
||||
name: 'Disabled Agent',
|
||||
command: '/usr/local/bin/disabled-agent',
|
||||
sdkBackend: 'codex',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'missing-backend-agent',
|
||||
name: 'Missing Backend Agent',
|
||||
command: '/usr/local/bin/missing-backend-agent',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
@@ -26,6 +34,7 @@ test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
|
||||
test('canSendWithAgent blocks missing or disabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('disabled-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-backend-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-agent', agents), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
|
||||
import { getExternalAgentSdkBackend } from "../../infrastructure/ai/managedAgents";
|
||||
|
||||
export function findEnabledExternalAgent(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentId: string,
|
||||
): ExternalAgentConfig | undefined {
|
||||
return agents.find((agent) => agent.id === agentId && agent.enabled);
|
||||
return agents.find((agent) => agent.id === agentId && agent.enabled && Boolean(getExternalAgentSdkBackend(agent)));
|
||||
}
|
||||
|
||||
export function canSendWithAgent(
|
||||
|
||||
@@ -15,11 +15,12 @@ test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", ()
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(result.configDir, "/cfg");
|
||||
assert.equal(result.settingsPath, "");
|
||||
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
});
|
||||
|
||||
test("splitClaudeEnv handles undefined env", () => {
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", settingsPath: "", envText: "" });
|
||||
});
|
||||
|
||||
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
|
||||
@@ -35,7 +36,7 @@ test("serializeEnvLines is the inverse for simple entries", () => {
|
||||
|
||||
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
|
||||
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
|
||||
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
|
||||
const next = buildClaudeEnv(prev, "/cfg", "", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
@@ -44,14 +45,15 @@ test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECU
|
||||
});
|
||||
|
||||
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
|
||||
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
|
||||
assert.equal(buildClaudeEnv(undefined, " ", " ", ""), undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
const next = buildClaudeEnv(
|
||||
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
|
||||
"/cfg",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
|
||||
"",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nNETCATTY_CLAUDE_SETTINGS=/evil/settings.json\nANTHROPIC_API_KEY=sk-x",
|
||||
);
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
@@ -59,3 +61,28 @@ test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("splitClaudeEnv + buildClaudeEnv round-trip the settings marker (NETCATTY_CLAUDE_SETTINGS)", () => {
|
||||
const split = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(split.settingsPath, "/team/settings.json");
|
||||
assert.equal(split.configDir, "/cfg");
|
||||
// the marker is kept out of the free-text env editor
|
||||
assert.equal(split.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
|
||||
// config dir + settings coexist (settings is additive, not a replacement for CLAUDE_CONFIG_DIR)
|
||||
const rebuilt = buildClaudeEnv(undefined, "/cfg", "/team/settings.json", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(rebuilt, {
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
|
||||
// settings alone (no config dir) is allowed
|
||||
assert.deepEqual(buildClaudeEnv(undefined, "", "/only/settings.json", ""), {
|
||||
NETCATTY_CLAUDE_SETTINGS: "/only/settings.json",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
buildExternalAgentHistoryMessages,
|
||||
buildExternalAgentHistoryMessagesForBridge,
|
||||
} from "./externalAgentHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
@@ -22,14 +22,14 @@ function message(
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
test("buildExternalAgentHistoryMessages compacts older external agent context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("a3", "assistant", "建议 SDK agent history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
@@ -47,7 +47,7 @@ test("buildAcpHistoryMessages compacts older ACP context and keeps only recent r
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
@@ -64,17 +64,17 @@ test("buildAcpHistoryMessages compacts older ACP context and keeps only recent r
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
test("buildExternalAgentHistoryMessagesForBridge keeps fallback history available for stale SDK agent session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.equal(buildExternalAgentHistoryMessagesForBridge([], "sdk-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
buildExternalAgentHistoryMessagesForBridge(messages, "sdk-session-1"),
|
||||
buildExternalAgentHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
@@ -87,7 +87,7 @@ test("buildAcpHistoryMessages preserves older substantive user instructions outs
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
@@ -104,7 +104,7 @@ test("buildAcpHistoryMessages preserves older substantive user instructions outs
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
@@ -117,13 +117,13 @@ test("buildAcpHistoryMessages preserves short important user constraints outside
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
test("buildExternalAgentHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
@@ -152,13 +152,13 @@ test("buildAcpHistoryMessages does not treat pr inside ordinary words as importa
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
test("buildExternalAgentHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
@@ -188,13 +188,13 @@ test("buildAcpHistoryMessages prioritizes later durable instructions over older
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
@@ -213,13 +213,13 @@ test("buildAcpHistoryMessages preserves older substantive assistant context that
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
@@ -239,14 +239,14 @@ test("buildAcpHistoryMessages preserves short non-trivial user constraints that
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
test("buildExternalAgentHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
@@ -263,7 +263,7 @@ test("buildAcpHistoryMessages still drops one-word filler user messages", () =>
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
@@ -273,7 +273,7 @@ test("buildAcpHistoryMessages still drops one-word filler user messages", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
@@ -293,7 +293,7 @@ test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
@@ -309,7 +309,7 @@ test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
test("buildExternalAgentHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
@@ -334,7 +334,7 @@ test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is inte
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
@@ -344,7 +344,7 @@ test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is inte
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
test("buildExternalAgentHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
@@ -373,7 +373,7 @@ test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) wo
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
@@ -382,7 +382,7 @@ test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) wo
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
@@ -423,7 +423,7 @@ test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
@@ -431,7 +431,7 @@ test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
@@ -456,7 +456,7 @@ test("buildAcpHistoryMessages preserves short non-trivial assistant decisions th
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
@@ -464,7 +464,7 @@ test("buildAcpHistoryMessages preserves short non-trivial assistant decisions th
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
test("buildExternalAgentHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
@@ -483,7 +483,7 @@ test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' /
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
@@ -491,7 +491,7 @@ test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' /
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
test("buildExternalAgentHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
@@ -530,7 +530,7 @@ test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
@@ -539,7 +539,7 @@ test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
test("buildExternalAgentHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
@@ -570,7 +570,7 @@ test("buildAcpHistoryMessages does not duplicate recent raw turns into the compa
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
@@ -587,7 +587,7 @@ test("buildAcpHistoryMessages does not duplicate recent raw turns into the compa
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
@@ -622,7 +622,7 @@ test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
@@ -638,7 +638,7 @@ test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
test("buildExternalAgentHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
@@ -655,7 +655,7 @@ test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
@@ -10,7 +10,7 @@ type DurableUserLine = {
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy external agent
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
@@ -238,7 +238,7 @@ function toRawHistoryMessage(
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// ("use that output", "what did cat show?"). external agent replay only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
@@ -270,7 +270,7 @@ function buildCompactContext(
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
): ExternalAgentHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
@@ -354,7 +354,7 @@ function buildCompactContext(
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
"The external SDK agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
@@ -395,7 +395,7 @@ function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
export function buildExternalAgentHistoryMessages(messages: ChatMessage[]): ExternalAgentHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
@@ -427,12 +427,12 @@ export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMess
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
export function buildExternalAgentHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
): ExternalAgentHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
const historyMessages = buildExternalAgentHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
@@ -94,15 +94,15 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpListModels?: (
|
||||
acpCommand: string,
|
||||
acpArgs?: string[],
|
||||
aiSdkAgentListModels?: (
|
||||
sdkBackend: string,
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, 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 }>;
|
||||
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
ok: boolean;
|
||||
skills?: Array<{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Handles:
|
||||
* - Catty agent streaming via Vercel AI SDK `streamText`
|
||||
* - External agent streaming (ACP and raw process)
|
||||
* - External agent streaming through official SDK backends
|
||||
* - Text-delta batching via requestAnimationFrame
|
||||
* - Abort controller management
|
||||
* - Stream state tracking (per-session)
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import { generateText, pruneMessages, streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
@@ -25,11 +25,21 @@ import type {
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import {
|
||||
CONTEXT_COMPACTION_SYSTEM_PROMPT,
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
formatMessagesForCompaction,
|
||||
estimateUnknownTokens,
|
||||
keepRecentContextMessages,
|
||||
prepareContextCompaction,
|
||||
resolveContextWindow,
|
||||
} from '../../../infrastructure/ai/contextCompaction';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAgents';
|
||||
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
|
||||
import {
|
||||
@@ -39,6 +49,7 @@ import {
|
||||
mergeProviderContinuation,
|
||||
normalizeProviderContinuationOptions,
|
||||
withProviderContinuationSource,
|
||||
type OpenAIChatAssistantFields,
|
||||
type ProviderContinuation,
|
||||
} from '../../../infrastructure/ai/providerContinuation';
|
||||
|
||||
@@ -67,6 +78,11 @@ export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
|
||||
|
||||
type ModelMessageWithOpenAIChatFields = ModelMessage & {
|
||||
[OPENAI_CHAT_ASSISTANT_FIELDS]?: OpenAIChatAssistantFields;
|
||||
};
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
@@ -78,6 +94,45 @@ function emitStreamingStoreChange(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function rememberOpenAIChatAssistantFields(
|
||||
message: ModelMessage,
|
||||
fields: OpenAIChatAssistantFields | undefined,
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): void {
|
||||
fieldsByMessage.set(message, fields);
|
||||
(message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS] = fields;
|
||||
}
|
||||
|
||||
function getRememberedOpenAIChatAssistantFields(
|
||||
message: ModelMessage,
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): OpenAIChatAssistantFields | undefined {
|
||||
if (fieldsByMessage.has(message)) return fieldsByMessage.get(message);
|
||||
return (message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS];
|
||||
}
|
||||
|
||||
function modelMessageHasToolCall(message: ModelMessage): boolean {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) return false;
|
||||
return message.content.some((part) => part && typeof part === 'object' && (part as { type?: string }).type === 'tool-call');
|
||||
}
|
||||
|
||||
function collectOpenAIChatAssistantFieldsForMessages(
|
||||
messages: ModelMessage[],
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
|
||||
): Array<OpenAIChatAssistantFields | undefined> {
|
||||
const fields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
let previousMessageWasTool = false;
|
||||
for (const message of messages) {
|
||||
const needsContinuationFields = message.role === 'assistant'
|
||||
&& (modelMessageHasToolCall(message) || previousMessageWasTool);
|
||||
if (needsContinuationFields) {
|
||||
fields.push(getRememberedOpenAIChatAssistantFields(message, fieldsByMessage));
|
||||
}
|
||||
previousMessageWasTool = message.role === 'tool';
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
@@ -123,13 +178,13 @@ export interface UseAIChatStreamingReturn {
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
/** Send a message to an external SDK agent. */
|
||||
sendToExternalAgent: (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => Promise<void>;
|
||||
/** Report a streaming error to the chat. */
|
||||
@@ -522,7 +577,7 @@ export function useAIChatStreaming({
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
@@ -532,8 +587,9 @@ export function useAIChatStreaming({
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const sdkBackend = getExternalAgentSdkBackend(agentConfig);
|
||||
if (sdkBackend && bridge) {
|
||||
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Push terminal session metadata to MCP bridge
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
@@ -552,7 +608,7 @@ export function useAIChatStreaming({
|
||||
}
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
@@ -594,7 +650,7 @@ export function useAIChatStreaming({
|
||||
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
|
||||
// Only patch tool call name if the existing name is missing/generic
|
||||
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
|
||||
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
|
||||
const updatedToolCalls = toolName && !toolName.includes('sdk_agent_dynamic_tool') && msg.toolCalls
|
||||
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
@@ -621,7 +677,7 @@ export function useAIChatStreaming({
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
// Managed ACP agents (codex, claude) must resolve auth from their own
|
||||
// Managed SDK agents (codex, claude) must resolve auth from their own
|
||||
// CLI config/login state, so we deliberately pass no providerId here.
|
||||
// See issue #705 for Codex; same reasoning for Claude.
|
||||
undefined,
|
||||
@@ -634,23 +690,13 @@ export function useAIChatStreaming({
|
||||
userSkillsContext,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
|
||||
// Managed agents always route through the SDK path above.
|
||||
reportStreamError(
|
||||
sessionId,
|
||||
abortController.signal,
|
||||
'This agent has no SDK backend configured. Re-discover it in Settings -> AI.',
|
||||
);
|
||||
setStreamingForScope(sessionId, false);
|
||||
}
|
||||
}, [
|
||||
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
|
||||
@@ -747,6 +793,7 @@ export function useAIChatStreaming({
|
||||
};
|
||||
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
let previousHistoryMessageWasToolResult = false;
|
||||
for (const m of allMessages) {
|
||||
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
|
||||
@@ -811,9 +858,10 @@ export function useAIChatStreaming({
|
||||
}
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
|
||||
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
|
||||
sdkMessages.push(message);
|
||||
if (resolvedCalls.length > 0) {
|
||||
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.content) {
|
||||
@@ -831,12 +879,13 @@ export function useAIChatStreaming({
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
sdkMessages.push({
|
||||
const message: ModelMessage = {
|
||||
role: 'assistant',
|
||||
content: toAssistantModelContent(contentParts),
|
||||
});
|
||||
};
|
||||
sdkMessages.push(message);
|
||||
if (currentMessageFollowsToolResult) {
|
||||
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
@@ -888,12 +937,64 @@ export function useAIChatStreaming({
|
||||
return;
|
||||
}
|
||||
|
||||
const contextWindow = resolveContextWindow({
|
||||
provider: context.activeProvider,
|
||||
modelId: activeModelId,
|
||||
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
});
|
||||
const outputReserveTokens = Math.min(4096, Math.ceil(contextWindow * 0.05));
|
||||
const requestReserveTokens = outputReserveTokens + estimateUnknownTokens({
|
||||
systemPrompt,
|
||||
toolNames: Object.keys(tools),
|
||||
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
|
||||
});
|
||||
|
||||
let messagesForStream = sdkMessages;
|
||||
try {
|
||||
const compacted = await prepareContextCompaction({
|
||||
messages: sdkMessages,
|
||||
contextWindow,
|
||||
reservedTokens: requestReserveTokens,
|
||||
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
summarize: async (messagesToSummarize) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
|
||||
}],
|
||||
abortSignal: abortController.signal,
|
||||
maxOutputTokens: 1600,
|
||||
temperature: 0,
|
||||
});
|
||||
return result.text;
|
||||
},
|
||||
});
|
||||
messagesForStream = compacted.messages;
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) throw err;
|
||||
console.warn('[Catty] Context compaction failed; falling back to recent messages only:', err);
|
||||
messagesForStream = keepRecentContextMessages(sdkMessages, DEFAULT_PROTECT_RECENT_MESSAGES);
|
||||
}
|
||||
|
||||
messagesForStream = pruneMessages({
|
||||
messages: messagesForStream,
|
||||
reasoning: 'all',
|
||||
emptyMessages: 'remove',
|
||||
});
|
||||
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
|
||||
messagesForStream,
|
||||
openAIChatAssistantFieldsByMessage,
|
||||
);
|
||||
|
||||
await processCattyStream(
|
||||
sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
sdkMessages,
|
||||
messagesForStream,
|
||||
abortController.signal,
|
||||
assistantMsgId,
|
||||
context.activeProvider?.advancedParams,
|
||||
|
||||
@@ -11,8 +11,7 @@ test('buildManagedAgentState removes stale managed agents when path detection fa
|
||||
name: 'Codex CLI',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
acpCommand: 'codex-acp',
|
||||
acpArgs: [],
|
||||
sdkBackend: 'codex',
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
@@ -43,8 +42,7 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
name: 'Claude Code',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
acpCommand: 'claude-agent-acp',
|
||||
acpArgs: [],
|
||||
sdkBackend: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
@@ -68,7 +66,7 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
|
||||
test('buildManagedAgentState stores the system Claude executable for SDK runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
@@ -78,11 +76,31 @@ test('buildManagedAgentState stores the system Claude executable for ACP runs',
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.equal(state.agents[0].sdkBackend, 'claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores SDK backend keys for discovered managed agents', () => {
|
||||
const codexState = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codex',
|
||||
{ path: '/opt/homebrew/bin/codex', version: '1.0.0', available: true },
|
||||
);
|
||||
const copilotState = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'copilot',
|
||||
{ path: '/opt/homebrew/bin/copilot', version: '1.0.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(codexState.agents[0].sdkBackend, 'codex');
|
||||
assert.equal(copilotState.agents[0].sdkBackend, 'copilot');
|
||||
assert.equal(copilotState.agents[0].acpArgs, undefined);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
@@ -90,8 +108,7 @@ test('buildManagedAgentState does not remove user-created matching agents', () =
|
||||
name: 'My Claude Wrapper',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
acpCommand: 'claude-agent-acp',
|
||||
acpArgs: [],
|
||||
sdkBackend: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -113,8 +130,7 @@ test('buildManagedAgentState only rewrites settings-managed discovered agents',
|
||||
name: 'My Codex Wrapper',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
acpCommand: 'codex-acp',
|
||||
acpArgs: [],
|
||||
sdkBackend: 'codex',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -122,7 +138,7 @@ test('buildManagedAgentState only rewrites settings-managed discovered agents',
|
||||
agents,
|
||||
'my-codex-wrapper',
|
||||
'codex',
|
||||
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
|
||||
{ path: '/opt/netcatty/codex', version: 'Bundled legacy adapter', available: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { type Dispatch, type RefObject, type SetStateAction } from 'react
|
||||
import { Database, Github, History, Server, Trash2 } from 'lucide-react';
|
||||
import type { CloudProvider, SyncPayload } from '../../domain/sync';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { isProviderReadyForSync } from '../../domain/sync';
|
||||
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
|
||||
@@ -116,7 +116,11 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
error={
|
||||
sync.providers.onedrive.error
|
||||
? cleanOneDriveErrorMessage(sync.providers.onedrive.error)
|
||||
: undefined
|
||||
}
|
||||
disabled={isConnectDisabled('onedrive')}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Proxy Configuration Sub-Panel
|
||||
* Panel for configuring HTTP/SOCKS5 proxy settings
|
||||
* Panel for configuring HTTP/SOCKS5/ProxyCommand proxy settings
|
||||
*/
|
||||
import { Check, Globe, KeyRound, Trash2 } from 'lucide-react';
|
||||
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isValidProxyPort } from '../../domain/proxyProfiles';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { formatProxyConfigEndpoint, formatProxyConfigType, isProxyCommandConfig, isValidProxyPort } from '../../domain/proxyProfiles';
|
||||
import { ProxyConfig, ProxyProfile } from '../../types';
|
||||
import { AsidePanel, AsidePanelContent, type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
@@ -47,9 +46,12 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasInvalidManualProxyPort = hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
|
||||
const canSave = isUsingProfile || (hasManualProxyHost && !hasInvalidManualProxyPort);
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
|
||||
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
|
||||
const canSave = isUsingProfile || (hasManualProxyValue && !hasInvalidManualProxyPort);
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasInvalidManualProxyPort) return;
|
||||
onBack();
|
||||
@@ -104,10 +106,10 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{selectedProfile.config.type.toUpperCase()}
|
||||
{formatProxyConfigType(selectedProfile.config)}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedProfile.config.host}:{selectedProfile.config.port}
|
||||
{formatProxyConfigEndpoint(selectedProfile.config)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,56 +120,65 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
{!isUsingProfile && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('field.type')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'http')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'socks5')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={proxyConfig?.type || 'http'}
|
||||
onValueChange={(value) => onUpdateProxy('type', value as ProxyConfig['type'])}
|
||||
>
|
||||
<SelectTrigger aria-label={t('field.type')} className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="command">{t('hostDetails.proxyPanel.command')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
value={proxyConfig?.host || ""}
|
||||
onChange={(e) => onUpdateProxy('host', e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
|
||||
{isCommandProxy ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<SquareTerminal size={14} />
|
||||
<span>{t('hostDetails.proxyPanel.commandHelp')}</span>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t('hostDetails.port')}
|
||||
type="number"
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={proxyConfig?.port || ""}
|
||||
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
|
||||
className="h-10 w-20 text-center"
|
||||
aria-label={t('hostDetails.proxyPanel.commandPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.commandPlaceholder')}
|
||||
value={proxyConfig?.command || ""}
|
||||
onChange={(e) => onUpdateProxy('command', e.target.value)}
|
||||
className="h-10 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
value={proxyConfig?.host || ""}
|
||||
onChange={(e) => onUpdateProxy('host', e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
|
||||
<Input
|
||||
aria-label={t('hostDetails.port')}
|
||||
type="number"
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={proxyConfig?.port || ""}
|
||||
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
|
||||
className="h-10 w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasInvalidManualProxyPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('proxyProfiles.error.port')}
|
||||
@@ -175,7 +186,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
{!isCommandProxy && <Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
@@ -198,11 +209,11 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</Card>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || selectedProxyProfileId) && (
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* View Key Panel - Display SSH key details
|
||||
*/
|
||||
|
||||
import { Copy,Info } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Check, Copy, Info } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { copyToClipboard } from './utils';
|
||||
|
||||
interface ViewKeyPanelProps {
|
||||
@@ -20,6 +21,15 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
|
||||
onExport,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyPublicKey = useCallback(async () => {
|
||||
const ok = await copyToClipboard(keyItem.publicKey || '');
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [keyItem.publicKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -30,18 +40,34 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
|
||||
{keyItem.publicKey && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
|
||||
<div className="relative">
|
||||
<div className="p-3 bg-card border border-border/80 rounded-lg font-mono text-xs break-all max-h-32 overflow-y-auto">
|
||||
<div className="flex rounded-lg border border-border/80 bg-card overflow-hidden">
|
||||
<div className="flex-1 min-w-0 p-3 font-mono text-xs break-all max-h-32 overflow-y-auto">
|
||||
{keyItem.publicKey}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2 h-7 w-7"
|
||||
onClick={() => copyToClipboard(keyItem.publicKey || '')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
<div className="shrink-0 flex flex-col border-l border-border/60 p-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => void handleCopyPublicKey()}
|
||||
aria-label={
|
||||
copied
|
||||
? t('cloudSync.githubFlow.copied')
|
||||
: t('action.copyPublicKey')
|
||||
}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{copied
|
||||
? t('cloudSync.githubFlow.copied')
|
||||
: t('action.copyPublicKey')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
import { canSendWithAgent } from "../../ai/agentSendEligibility";
|
||||
|
||||
import type {
|
||||
AgentPathInfo,
|
||||
@@ -131,17 +132,18 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
|
||||
[externalAgents],
|
||||
);
|
||||
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
|
||||
() => splitClaudeEnv(claudeManagedEnv),
|
||||
[claudeManagedEnv],
|
||||
);
|
||||
const {
|
||||
configDir: claudeConfigDir,
|
||||
settingsPath: claudeSettingsPath,
|
||||
envText: claudeEnvText,
|
||||
} = useMemo(() => splitClaudeEnv(claudeManagedEnv), [claudeManagedEnv]);
|
||||
|
||||
const updateClaudeEnv = useCallback(
|
||||
(nextConfigDir: string, nextEnvText: string) => {
|
||||
(nextConfigDir: string, nextSettingsPath: string, nextEnvText: string) => {
|
||||
setExternalAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === "discovered_claude"
|
||||
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
|
||||
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextSettingsPath, nextEnvText) }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
@@ -276,10 +278,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const agentOptions = useMemo(() => [
|
||||
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
|
||||
...externalAgents
|
||||
.filter((a) => a.enabled)
|
||||
.filter((a) => canSendWithAgent(a.id, externalAgents))
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentOptions.some((option) => option.value === defaultAgentId)) {
|
||||
setDefaultAgentId("catty");
|
||||
}
|
||||
}, [agentOptions, defaultAgentId, setDefaultAgentId]);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
@@ -567,9 +575,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
configDir={claudeConfigDir}
|
||||
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
|
||||
onConfigDirChange={(v) => updateClaudeEnv(v, claudeSettingsPath, claudeEnvText)}
|
||||
settingsPath={claudeSettingsPath}
|
||||
onSettingsPathChange={(v) => updateClaudeEnv(claudeConfigDir, v, claudeEnvText)}
|
||||
envText={claudeEnvText}
|
||||
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
|
||||
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, claudeSettingsPath, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ interface TempDirInfo {
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
interface SshDebugLogInfo {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
@@ -76,6 +83,8 @@ interface SettingsSystemTabProps {
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
setSshDebugLogsEnabled: (enabled: boolean) => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
@@ -100,6 +109,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
@@ -132,6 +143,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
|
||||
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
|
||||
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
|
||||
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
|
||||
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
|
||||
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
@@ -196,6 +209,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
void loadCrashLogs();
|
||||
}, [loadCrashLogs]);
|
||||
|
||||
const loadSshDebugLogInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSshDebugLogInfo) return;
|
||||
setIsLoadingSshDebugLogInfo(true);
|
||||
try {
|
||||
const info = await bridge.getSshDebugLogInfo();
|
||||
setSshDebugLogInfo(info);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to load SSH debug log info:", err);
|
||||
} finally {
|
||||
setIsLoadingSshDebugLogInfo(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSshDebugLogInfo();
|
||||
}, [loadSshDebugLogInfo, sshDebugLogsEnabled]);
|
||||
|
||||
const expandRequestRef = React.useRef(0);
|
||||
const handleExpandCrashLog = useCallback(async (fileName: string) => {
|
||||
if (expandedLog === fileName) {
|
||||
@@ -294,6 +325,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
const handleOpenSshDebugLogDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSshDebugLogDir) return;
|
||||
await bridge.openSshDebugLogDir();
|
||||
}, []);
|
||||
|
||||
// Handle global toggle hotkey recording
|
||||
const cancelHotkeyRecording = useCallback(() => {
|
||||
setIsRecordingHotkey(false);
|
||||
@@ -877,6 +914,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSH Debug Logs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.sshDebugLogs.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
<SettingRow
|
||||
label={t("settings.sshDebugLogs.enable")}
|
||||
description={t("settings.sshDebugLogs.enableDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={sshDebugLogsEnabled}
|
||||
onChange={setSshDebugLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t("settings.sshDebugLogs.location")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
|
||||
{isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadSshDebugLogInfo}
|
||||
disabled={isLoadingSshDebugLogInfo}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoadingSshDebugLogInfo ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSshDebugLogDir}
|
||||
className="shrink-0"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("settings.sshDebugLogs.status")}:{" "}
|
||||
{sshDebugLogsEnabled ? t("settings.sshDebugLogs.statusOn") : t("settings.sshDebugLogs.statusOff")}
|
||||
</span>
|
||||
<span>
|
||||
{t("settings.sshDebugLogs.size")}: {formatBytes(sshDebugLogInfo?.size ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.sshDebugLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global Toggle Window Section (Quake Mode) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { customThemeStore, useCustomThemes } from "../../../application/state/cu
|
||||
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
|
||||
import { parseShellArgs, formatShellArgs } from "../../../domain/shellArgs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
@@ -85,12 +86,21 @@ export default function SettingsTerminalTab(props: {
|
||||
});
|
||||
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
|
||||
const [customShellDraft, setCustomShellDraft] = useState("");
|
||||
const [customArgsDraft, setCustomArgsDraft] = useState("");
|
||||
|
||||
// Update showCustomShellInput once discovered shells load
|
||||
useEffect(() => {
|
||||
if (!terminalSettings.localShell) return;
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
|
||||
// Seed the drafts from current settings and open the custom-shell editor.
|
||||
// Used both when picking "Custom…" and when re-editing an existing custom shell.
|
||||
const openCustomShellModal = useCallback(() => {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomArgsDraft(formatShellArgs(terminalSettings.localShellArgs ?? []));
|
||||
setCustomShellModalOpen(true);
|
||||
}, [terminalSettings.localShell, terminalSettings.localShellArgs]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
|
||||
|
||||
@@ -682,14 +692,17 @@ export default function SettingsTerminalTab(props: {
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
openCustomShellModal();
|
||||
} else if (value === "__default__") {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", "");
|
||||
// Custom args only apply to a custom path; clear them so a stale
|
||||
// value can't leak into a discovered/default shell launch (#1221).
|
||||
updateTerminalSetting("localShellArgs", []);
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
updateTerminalSetting("localShellArgs", []);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -710,9 +723,18 @@ export default function SettingsTerminalTab(props: {
|
||||
</SelectContent>
|
||||
</ShadcnSelect>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCustomShellModal}
|
||||
title={t("common.edit")}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground max-w-48 hover:text-foreground"
|
||||
>
|
||||
<Pencil size={11} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{terminalSettings.localShell}
|
||||
{terminalSettings.localShellArgs?.length ? ` ${formatShellArgs(terminalSettings.localShellArgs)}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -930,6 +952,16 @@ export default function SettingsTerminalTab(props: {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customArgs")}</label>
|
||||
<Input
|
||||
value={customArgsDraft}
|
||||
placeholder={t("settings.terminal.localShell.shell.customArgs.placeholder")}
|
||||
onChange={(e) => setCustomArgsDraft(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.customArgs.desc")}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -958,6 +990,7 @@ export default function SettingsTerminalTab(props: {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTerminalSetting("localShell", customShellDraft);
|
||||
updateTerminalSetting("localShellArgs", parseShellArgs(customArgsDraft));
|
||||
setShowCustomShellInput(true);
|
||||
setCustomShellModalOpen(false);
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const ADD_PROVIDER_MENU_CLASS =
|
||||
"absolute top-full right-0 mt-1 z-[101] min-w-[220px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover shadow-md py-1";
|
||||
|
||||
export const AddProviderDropdown: React.FC<{
|
||||
onAdd: (providerId: AIProviderId) => void;
|
||||
}> = ({ onAdd }) => {
|
||||
@@ -33,7 +36,7 @@ export const AddProviderDropdown: React.FC<{
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
|
||||
<div className={ADD_PROVIDER_MENU_CLASS}>
|
||||
{providerIds.map((pid) => (
|
||||
<button
|
||||
key={pid}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const ClaudeCodeCard: React.FC<{
|
||||
onRecheckPath: () => void;
|
||||
configDir: string;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
settingsPath: string;
|
||||
onSettingsPathChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
}> = ({
|
||||
@@ -25,6 +27,8 @@ export const ClaudeCodeCard: React.FC<{
|
||||
onRecheckPath,
|
||||
configDir,
|
||||
onConfigDirChange,
|
||||
settingsPath,
|
||||
onSettingsPathChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
}) => {
|
||||
@@ -33,7 +37,7 @@ export const ClaudeCodeCard: React.FC<{
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(configDir.trim() || envText.trim()),
|
||||
() => Boolean(configDir.trim() || settingsPath.trim() || envText.trim()),
|
||||
);
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
@@ -140,6 +144,18 @@ export const ClaudeCodeCard: React.FC<{
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-settings" className="text-xs text-muted-foreground">{t('ai.claude.settings')}</label>
|
||||
<input
|
||||
id="claude-settings"
|
||||
type="text"
|
||||
value={settingsPath}
|
||||
onChange={(e) => onSettingsPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.settings.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.settings.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
|
||||
<textarea
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
@@ -9,19 +9,90 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
import { parseFetchedModels } from "./modelMetadata";
|
||||
|
||||
export function buildModelSuggestions({
|
||||
presetModels,
|
||||
fetchedModels,
|
||||
hasFetched,
|
||||
value,
|
||||
}: {
|
||||
presetModels?: readonly string[];
|
||||
fetchedModels: FetchedModel[];
|
||||
hasFetched: boolean;
|
||||
value: string;
|
||||
}): FetchedModel[] {
|
||||
const byId = new Map<string, FetchedModel>();
|
||||
for (const modelId of presetModels ?? []) {
|
||||
const id = modelId.trim();
|
||||
if (id) byId.set(id, { id });
|
||||
}
|
||||
if (hasFetched) {
|
||||
for (const model of fetchedModels) {
|
||||
byId.set(model.id, model);
|
||||
}
|
||||
}
|
||||
|
||||
const allSuggestions = Array.from(byId.values());
|
||||
if (!value.trim()) return allSuggestions;
|
||||
const q = value.toLowerCase();
|
||||
return allSuggestions.filter((m) =>
|
||||
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelSuggestionsPresentation({
|
||||
suggestionsLength,
|
||||
isLoading,
|
||||
error,
|
||||
hasFetched,
|
||||
hasPresetModels,
|
||||
}: {
|
||||
suggestionsLength: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasFetched: boolean;
|
||||
hasPresetModels: boolean;
|
||||
}): {
|
||||
showSuggestions: boolean;
|
||||
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
|
||||
footerState: "loading" | "error" | null;
|
||||
} {
|
||||
if (suggestionsLength > 0) {
|
||||
return {
|
||||
showSuggestions: true,
|
||||
emptyState: null,
|
||||
footerState: isLoading ? "loading" : error ? "error" : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return { showSuggestions: false, emptyState: "loading", footerState: null };
|
||||
}
|
||||
if (error) {
|
||||
return { showSuggestions: false, emptyState: "error", footerState: null };
|
||||
}
|
||||
return {
|
||||
showSuggestions: false,
|
||||
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
|
||||
footerState: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
baseURL: string;
|
||||
modelsEndpoint?: string;
|
||||
presetModels?: readonly string[];
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
skipTLSVerify?: boolean;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, style, skipTLSVerify }) => {
|
||||
onModelMetadata?: (model: FetchedModel) => void;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -39,11 +110,30 @@ export const ModelSelector: React.FC<{
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
const hasPresetModels = (presetModels?.length ?? 0) > 0;
|
||||
const canSuggest = canFetch || hasPresetModels;
|
||||
const discoveryKey = JSON.stringify({
|
||||
baseURL,
|
||||
effectiveModelsEndpoint,
|
||||
apiKey,
|
||||
resolvedStyle,
|
||||
skipTLSVerify,
|
||||
});
|
||||
const discoveryKeyRef = useRef(discoveryKey);
|
||||
|
||||
useEffect(() => {
|
||||
discoveryKeyRef.current = discoveryKey;
|
||||
setModels([]);
|
||||
setHasFetched(false);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}, [discoveryKey]);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!effectiveModelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
const requestKey = discoveryKey;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -57,23 +147,23 @@ export const ModelSelector: React.FC<{
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(result.data);
|
||||
const list: FetchedModel[] = (parsed.data || parsed.models || []).map((m: { id: string; name?: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
}));
|
||||
const list = parseFetchedModels(parsed);
|
||||
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setModels(list);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
if (discoveryKeyRef.current !== requestKey) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to parse response");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify]);
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
@@ -82,17 +172,24 @@ export const ModelSelector: React.FC<{
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
|
||||
// Filter models by current input value (inline autocomplete)
|
||||
// Filter preset and discovered models by current input value (inline autocomplete).
|
||||
const suggestions = useMemo(() => {
|
||||
if (!hasFetched || models.length === 0) return [];
|
||||
if (!value.trim()) return models;
|
||||
const q = value.toLowerCase();
|
||||
return models.filter((m) =>
|
||||
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [models, value, hasFetched]);
|
||||
return buildModelSuggestions({
|
||||
presetModels,
|
||||
fetchedModels: models,
|
||||
hasFetched,
|
||||
value,
|
||||
});
|
||||
}, [models, presetModels, value, hasFetched]);
|
||||
|
||||
const showSuggestions = isOpen && canFetch;
|
||||
const showSuggestions = isOpen && canSuggest;
|
||||
const presentation = getModelSuggestionsPresentation({
|
||||
suggestionsLength: suggestions.length,
|
||||
isLoading,
|
||||
error,
|
||||
hasFetched,
|
||||
hasPresetModels,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -103,17 +200,17 @@ export const ModelSelector: React.FC<{
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (canFetch && hasFetched && !isOpen) setIsOpen(true);
|
||||
if (canSuggest && !isOpen) setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (canFetch) setIsOpen(true); }}
|
||||
onFocus={() => { if (canSuggest) setIsOpen(true); }}
|
||||
onBlur={() => { setIsOpen(false); }}
|
||||
placeholder={placeholder ?? (canFetch ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
|
||||
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
canFetch && "pr-8",
|
||||
canSuggest && "pr-8",
|
||||
)}
|
||||
/>
|
||||
{canFetch && (
|
||||
{canSuggest && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
||||
@@ -145,16 +242,20 @@ export const ModelSelector: React.FC<{
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
{!presentation.showSuggestions ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-destructive">{error}</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
{hasFetched ? t('ai.providers.noMatchingModels') : t('ai.providers.clickToLoadModels')}
|
||||
{presentation.emptyState === "loading" ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</>
|
||||
) : presentation.emptyState === "error" ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : presentation.emptyState === "noMatches" ? (
|
||||
t('ai.providers.noMatchingModels')
|
||||
) : (
|
||||
t('ai.providers.clickToLoadModels')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
suggestions.slice(0, 100).map((m) => (
|
||||
@@ -163,6 +264,7 @@ export const ModelSelector: React.FC<{
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(m.id);
|
||||
onModelMetadata?.(m);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
@@ -175,6 +277,21 @@ export const ModelSelector: React.FC<{
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{presentation.footerState && (
|
||||
<div className={cn(
|
||||
"px-3 py-2 text-center text-[10px] border-t border-border/40",
|
||||
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{presentation.footerState === "loading" ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin inline mr-1" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{suggestions.length > 100 && (
|
||||
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
|
||||
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
@@ -10,6 +11,7 @@ import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { mergeModelContextWindow } from "./modelMetadata";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
const ICON_PIXEL_SIZE = 64;
|
||||
@@ -60,6 +62,8 @@ export const ProviderConfigForm: React.FC<{
|
||||
apiKey: "",
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "",
|
||||
modelContextWindows: provider.modelContextWindows ?? {},
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
style: provider.style ?? "",
|
||||
@@ -71,9 +75,28 @@ export const ProviderConfigForm: React.FC<{
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
|
||||
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
|
||||
providerId: provider.providerId,
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
apiKeySourceVersion,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
}), [
|
||||
provider.providerId,
|
||||
form.baseURL,
|
||||
apiKeySourceVersion,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const modelMetadataSourceKeyRef = useRef<string | null>(null);
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
@@ -97,6 +120,19 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelMetadataSourceKeyRef.current == null) {
|
||||
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
|
||||
return;
|
||||
}
|
||||
if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return;
|
||||
|
||||
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
|
||||
setForm((prev) => Object.keys(prev.modelContextWindows).length > 0
|
||||
? { ...prev, modelContextWindows: {} }
|
||||
: prev);
|
||||
}, [modelMetadataSourceKey]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
@@ -139,6 +175,11 @@ export const ProviderConfigForm: React.FC<{
|
||||
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
|
||||
}, []);
|
||||
|
||||
const handleApiKeyChange = useCallback((value: string) => {
|
||||
setApiKeySourceVersion((version) => version + 1);
|
||||
setForm((prev) => ({ ...prev, apiKey: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
@@ -150,11 +191,25 @@ export const ProviderConfigForm: React.FC<{
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
const rawContextWindow = form.contextWindow.trim();
|
||||
const rawContextWindowNumber = Number(rawContextWindow);
|
||||
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
|
||||
setContextWindowError(t("ai.providers.contextWindow.error"));
|
||||
return;
|
||||
}
|
||||
const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined;
|
||||
if (rawContextWindow && manualContextWindow == null) {
|
||||
setContextWindowError(t("ai.providers.contextWindow.error"));
|
||||
return;
|
||||
}
|
||||
setContextWindowError(null);
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
name: trimmedName || defaultName,
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
contextWindow: manualContextWindow,
|
||||
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
style: form.style || undefined,
|
||||
@@ -170,7 +225,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, provider.providerId]);
|
||||
}, [form, onSave, provider.providerId, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
@@ -305,7 +360,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
@@ -339,8 +394,15 @@ export const ProviderConfigForm: React.FC<{
|
||||
<ModelSelector
|
||||
value={form.defaultModel}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
|
||||
onModelMetadata={(model) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
|
||||
}));
|
||||
}}
|
||||
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
presetModels={preset?.defaultModels}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
style={resolvedStyle}
|
||||
@@ -348,6 +410,32 @@ export const ProviderConfigForm: React.FC<{
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context window */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.contextWindow')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={form.contextWindow}
|
||||
onChange={(e) => {
|
||||
setContextWindowError(null);
|
||||
setForm((prev) => ({ ...prev, contextWindow: e.target.value }));
|
||||
}}
|
||||
placeholder={
|
||||
form.defaultModel && form.modelContextWindows[form.defaultModel]
|
||||
? String(form.modelContextWindows[form.defaultModel])
|
||||
: t('ai.providers.contextWindow.placeholder')
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
contextWindowError && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
{contextWindowError && <p className="text-[11px] text-destructive">{contextWindowError}</p>}
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.contextWindow.help')}</p>
|
||||
</div>
|
||||
|
||||
{/* Skip TLS Verification */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
*/
|
||||
|
||||
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
|
||||
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
|
||||
// netcatty marker carrying the claude SDK `settings` option (a settings.json
|
||||
// path or inline JSON). Extracted in the main process and passed to the SDK as
|
||||
// `options.settings`; never sent to the agent as a real env var. Additive to —
|
||||
// and independent of — CLAUDE_CONFIG_DIR.
|
||||
const SETTINGS_KEY = "NETCATTY_CLAUDE_SETTINGS";
|
||||
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY, SETTINGS_KEY]);
|
||||
|
||||
export function parseEnvLines(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
@@ -31,20 +36,22 @@ export function serializeEnvLines(env: Record<string, string>): string {
|
||||
|
||||
export function splitClaudeEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): { configDir: string; envText: string } {
|
||||
if (!env) return { configDir: "", envText: "" };
|
||||
): { configDir: string; settingsPath: string; envText: string } {
|
||||
if (!env) return { configDir: "", settingsPath: "", envText: "" };
|
||||
const configDir = env[CONFIG_DIR_KEY] ?? "";
|
||||
const settingsPath = env[SETTINGS_KEY] ?? "";
|
||||
const rest: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (MANAGED_KEYS.has(k)) continue;
|
||||
rest[k] = v;
|
||||
}
|
||||
return { configDir, envText: serializeEnvLines(rest) };
|
||||
return { configDir, settingsPath, envText: serializeEnvLines(rest) };
|
||||
}
|
||||
|
||||
export function buildClaudeEnv(
|
||||
prevEnv: Record<string, string> | undefined,
|
||||
configDir: string,
|
||||
settingsPath: string,
|
||||
envText: string,
|
||||
): Record<string, string> | undefined {
|
||||
const next: Record<string, string> = {};
|
||||
@@ -55,8 +62,11 @@ export function buildClaudeEnv(
|
||||
const trimmedDir = String(configDir || "").trim();
|
||||
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
|
||||
|
||||
const trimmedSettings = String(settingsPath || "").trim();
|
||||
if (trimmedSettings) next[SETTINGS_KEY] = trimmedSettings;
|
||||
|
||||
// Drop managed keys if a user typed them into the free-text editor — the
|
||||
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
|
||||
// dedicated fields and path discovery own these keys.
|
||||
const parsed = parseEnvLines(envText);
|
||||
for (const key of MANAGED_KEYS) delete parsed[key];
|
||||
Object.assign(next, parsed);
|
||||
|
||||
37
components/settings/tabs/ai/dropdownLayout.test.ts
Normal file
37
components/settings/tabs/ai/dropdownLayout.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ADD_PROVIDER_MENU_CLASS } from "./AddProviderDropdown.tsx";
|
||||
import { getModelSuggestionsPresentation } from "./ModelSelector.tsx";
|
||||
|
||||
test("add provider menu opens toward the left edge of the button and stays width-bounded", () => {
|
||||
assert.match(ADD_PROVIDER_MENU_CLASS, /right-0/);
|
||||
assert.doesNotMatch(ADD_PROVIDER_MENU_CLASS, /left-0/);
|
||||
assert.match(ADD_PROVIDER_MENU_CLASS, /max-w-\[calc\(100vw-2rem\)\]/);
|
||||
});
|
||||
|
||||
test("preset model suggestions stay visible while remote models are loading", () => {
|
||||
assert.deepEqual(
|
||||
getModelSuggestionsPresentation({
|
||||
suggestionsLength: 2,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasFetched: false,
|
||||
hasPresetModels: true,
|
||||
}),
|
||||
{ showSuggestions: true, emptyState: null, footerState: "loading" },
|
||||
);
|
||||
});
|
||||
|
||||
test("preset model suggestions stay visible when remote model discovery fails", () => {
|
||||
assert.deepEqual(
|
||||
getModelSuggestionsPresentation({
|
||||
suggestionsLength: 2,
|
||||
isLoading: false,
|
||||
error: "Failed to fetch models",
|
||||
hasFetched: false,
|
||||
hasPresetModels: true,
|
||||
}),
|
||||
{ showSuggestions: true, emptyState: null, footerState: "error" },
|
||||
);
|
||||
});
|
||||
@@ -46,12 +46,17 @@ export function buildManagedAgentState(
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const {
|
||||
acpCommand: _legacyCommand,
|
||||
acpArgs: _legacyArgs,
|
||||
...existingManagedWithoutLegacy
|
||||
} = existingManaged ?? {};
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const managedEnv = agentKey === "claude"
|
||||
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
|
||||
: existingManaged?.env;
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...existingManagedWithoutLegacy,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
|
||||
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal file
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
mergeModelContextWindow,
|
||||
parseFetchedModels,
|
||||
} from "./modelMetadata.ts";
|
||||
import { buildModelSuggestions } from "./ModelSelector.tsx";
|
||||
|
||||
test("parseFetchedModels reads common context window fields from model list responses", () => {
|
||||
assert.deepEqual(
|
||||
parseFetchedModels({
|
||||
data: [
|
||||
{ id: "openrouter/model", name: "OpenRouter Model", context_length: 131072 },
|
||||
{ id: "vercel/model", context_window: 262144 },
|
||||
{ id: "custom/model", contextWindow: 65536 },
|
||||
],
|
||||
}),
|
||||
[
|
||||
{ id: "openrouter/model", name: "OpenRouter Model", contextWindow: 131072 },
|
||||
{ id: "vercel/model", contextWindow: 262144 },
|
||||
{ id: "custom/model", contextWindow: 65536 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeModelContextWindow stores valid discovered model windows only", () => {
|
||||
assert.deepEqual(
|
||||
mergeModelContextWindow(undefined, "qwen", 262144),
|
||||
{ qwen: 262144 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeModelContextWindow({ old: 8192 }, "qwen", undefined),
|
||||
{ old: 8192 },
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModelSuggestions uses provider presets before fetched model discovery", () => {
|
||||
assert.deepEqual(
|
||||
buildModelSuggestions({
|
||||
presetModels: ["qwen3.6-plus", "qwen3.6-flash"],
|
||||
fetchedModels: [],
|
||||
hasFetched: false,
|
||||
value: "plus",
|
||||
}),
|
||||
[{ id: "qwen3.6-plus" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModelSuggestions merges fetched and preset models without duplicates", () => {
|
||||
assert.deepEqual(
|
||||
buildModelSuggestions({
|
||||
presetModels: ["kimi-k2.6", "moonshot-v1-128k"],
|
||||
fetchedModels: [
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "moonshot-v1-8k", name: "Moonshot 8K" },
|
||||
],
|
||||
hasFetched: true,
|
||||
value: "",
|
||||
}).map((model) => model.id),
|
||||
["kimi-k2.6", "moonshot-v1-128k", "moonshot-v1-8k"],
|
||||
);
|
||||
});
|
||||
44
components/settings/tabs/ai/modelMetadata.ts
Normal file
44
components/settings/tabs/ai/modelMetadata.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import type { FetchedModel } from "./types";
|
||||
|
||||
export function parseFetchedModels(parsed: unknown): FetchedModel[] {
|
||||
const record = parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
||||
const rawModels = Array.isArray(record.data)
|
||||
? record.data
|
||||
: Array.isArray(record.models)
|
||||
? record.models
|
||||
: [];
|
||||
|
||||
return rawModels
|
||||
.map((raw): FetchedModel | null => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const model = raw as Record<string, unknown>;
|
||||
if (typeof model.id !== "string" || !model.id) return null;
|
||||
return {
|
||||
id: model.id,
|
||||
...(typeof model.name === "string" ? { name: model.name } : {}),
|
||||
...(resolveModelContextWindow(model) != null ? { contextWindow: resolveModelContextWindow(model) } : {}),
|
||||
};
|
||||
})
|
||||
.filter((model): model is FetchedModel => model != null);
|
||||
}
|
||||
|
||||
export function mergeModelContextWindow(
|
||||
current: Record<string, number> | undefined,
|
||||
modelId: string,
|
||||
contextWindow: number | undefined,
|
||||
): Record<string, number> | undefined {
|
||||
const sanitized = sanitizeContextWindow(contextWindow);
|
||||
if (!modelId || sanitized == null) return current;
|
||||
return { ...(current ?? {}), [modelId]: sanitized };
|
||||
}
|
||||
|
||||
function resolveModelContextWindow(model: Record<string, unknown>): number | undefined {
|
||||
return sanitizeContextWindow(
|
||||
model.context_length
|
||||
?? model.context_window
|
||||
?? model.contextWindow
|
||||
?? model.context
|
||||
?? model.max_context_tokens,
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,8 @@ export interface ProviderFormState {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
contextWindow: string;
|
||||
modelContextWindows: Record<string, number>;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
style: ProviderStyle | ""; // "" means inherit-from-providerId
|
||||
@@ -88,6 +90,7 @@ export interface ProviderFormState {
|
||||
export interface FetchedModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
@@ -113,22 +116,19 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
name: "Codex CLI",
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
icon: "openai",
|
||||
acpCommand: "codex-acp",
|
||||
acpArgs: [],
|
||||
sdkBackend: "codex",
|
||||
},
|
||||
claude: {
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
sdkBackend: "claude",
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
sdkBackend: "copilot",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -166,6 +166,12 @@ export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
qwen: "/ai/providers/qwen.svg",
|
||||
deepseek: "/ai/providers/deepseek.svg",
|
||||
kimi: "/ai/providers/kimi.svg",
|
||||
zhipu: "/ai/providers/zhipu.svg",
|
||||
doubao: "/ai/providers/doubao.svg",
|
||||
mimo: "/ai/providers/xiaomi.svg",
|
||||
custom: "/ai/providers/custom.svg",
|
||||
};
|
||||
|
||||
@@ -177,6 +183,12 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
qwen: "bg-[#615CED]",
|
||||
deepseek: "bg-[#4D6BFE]",
|
||||
kimi: "bg-zinc-800",
|
||||
zhipu: "bg-[#3859FF]",
|
||||
doubao: "bg-[#0066FF]",
|
||||
mimo: "bg-[#FF6900]",
|
||||
custom: "bg-zinc-600",
|
||||
};
|
||||
|
||||
@@ -210,6 +222,7 @@ export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
|
||||
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
|
||||
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
|
||||
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
|
||||
{ id: "xiaomi", label: "Xiaomi / 小米", name: "Xiaomi MiMo", path: "/ai/providers/xiaomi.svg", bgColor: "bg-[#FF6900]" },
|
||||
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
|
||||
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
|
||||
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },
|
||||
|
||||
95
components/sftp/SftpClipboardUploadDialog.tsx
Normal file
95
components/sftp/SftpClipboardUploadDialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import type { SftpClipboardUploadRequest } from "./clipboardUpload";
|
||||
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
||||
|
||||
interface SftpClipboardUploadDialogProps {
|
||||
request: SftpClipboardUploadRequest | null;
|
||||
currentPath?: string;
|
||||
onUploaded?: (targetPath: string) => void;
|
||||
}
|
||||
|
||||
export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps> = ({
|
||||
request,
|
||||
currentPath,
|
||||
onUploaded,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const open = !!request;
|
||||
const fileCount = request?.files.length ?? 0;
|
||||
const previewFiles = request?.files.slice(0, 5) ?? [];
|
||||
const remainingCount = Math.max(0, fileCount - previewFiles.length);
|
||||
|
||||
const handleClose = (nextOpen: boolean) => {
|
||||
if (nextOpen || uploading) return;
|
||||
sftpClipboardUploadStore.clear(request);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!request) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await request.onConfirm();
|
||||
onUploaded?.(request.targetPath);
|
||||
sftpClipboardUploadStore.clear(request);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload clipboard files?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all">
|
||||
{request?.targetPath ?? currentPath}
|
||||
</div>
|
||||
{previewFiles.length > 0 && (
|
||||
<div className="max-h-40 overflow-auto rounded-md border border-border/60">
|
||||
{previewFiles.map((file) => (
|
||||
<div key={file.path} className="px-3 py-2 text-sm border-b border-border/40 last:border-b-0 truncate">
|
||||
{file.name}
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
and {remainingCount} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sftpClipboardUploadStore.clear(request)}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={uploading || !request}>
|
||||
{uploading && <Loader2 size={14} className="mr-2 animate-spin" />}
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -50,6 +50,7 @@ export interface SftpPaneCallbacks {
|
||||
// File operations
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user